dsh-plugin-lookatstudy 0.14.0 → 0.15.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
@@ -4,6 +4,18 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region \0rolldown/runtime.js
8
+ var __defProp = Object.defineProperty;
9
+ var __exportAll = (all, no_symbols) => {
10
+ let target = {};
11
+ for (var name in all) __defProp(target, name, {
12
+ get: all[name],
13
+ enumerable: true
14
+ });
15
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
16
+ return target;
17
+ };
18
+ //#endregion
7
19
  let react = require("react");
8
20
  let react_dom_client = require("react-dom/client");
9
21
  //#region src/client/styles.ts
@@ -38,7 +50,7 @@ window.__ModuleLoader__.load({
38
50
  every surface family that consumes the inks: .lks-root (dock pill,
39
51
  settings) and .lks-tv (toolview cards, which render in the host
40
52
  conversation with no .lks-root ancestor). */
41
- .lks-root,.lks-tv{--lks-warn-ink:color-mix(in srgb,var(--dsw-alias-state-warn-label) 45%,var(--dsw-alias-label-primary));--lks-ok-ink:color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,var(--dsw-alias-label-primary));--lks-err-ink:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,var(--dsw-alias-label-primary))}
53
+ .lks-root,.lks-tv,.lks14{--lks-warn-ink:color-mix(in srgb,var(--dsw-alias-state-warn-label) 45%,var(--dsw-alias-label-primary));--lks-ok-ink:color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,var(--dsw-alias-label-primary));--lks-err-ink:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,var(--dsw-alias-label-primary))}
42
54
  .lks-root{font-family:var(--dsw-font-family)}
43
55
  .lks-root :where(button){font-family:var(--dsw-font-family);cursor:pointer;border:none;background:none;padding:0}
44
56
 
@@ -59,7 +71,7 @@ html[data-dsh-lookatstudy-active] .lks14-shell-view{display:flex;flex-direction:
59
71
  html[data-dsh-lookatstudy-active] [data-pane='conversation'] > :not([data-dsh-lookatstudy-view]),
60
72
  html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatstudy-view]){display:none !important}
61
73
  [data-dsh-lookatstudy-view]{container-type:inline-size}
62
- .lks14{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
74
+ .lks14{position:relative;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
63
75
  .lks14 button{font-family:var(--dsw-font-family);cursor:pointer}
64
76
  .lks14-body{display:flex;flex:1;min-width:0;min-height:0}
65
77
  .lks14-col{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
@@ -152,6 +164,153 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
152
164
  .lks14-zone{margin-bottom:12px}
153
165
  .lks14-zoneh{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
154
166
 
167
+
168
+ /* toast capsules (P6, upstream Toast port): top-center under the panel head,
169
+ container inert to pointers, items auto; exit is an animation handshake —
170
+ reduced-motion keeps a .01ms duration so the end event still fires. */
171
+ .lks-toasts{position:absolute;top:44px;left:50%;transform:translateX(-50%);z-index:40;display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;max-width:min(440px,90%)}
172
+ .lks-toast{pointer-events:auto;display:flex;align-items:center;gap:8px;max-width:100%;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:6px 8px 6px 14px;font-size:13px;line-height:1.4;box-shadow:0 6px 24px rgba(0,0,0,.14);animation:lks-toast-in .18s ease}
173
+ .lks-toast.exiting{animation:lks-toast-out .18s ease forwards}
174
+ .lks-toast .lks-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
175
+ .lks-toast .lks-toast-glyph{flex:none}
176
+ .lks-toast .lks-toast-glyph.ok{color:var(--lks-ok-ink)}
177
+ .lks-toast .lks-toast-glyph.warn{color:var(--lks-warn-ink)}
178
+ .lks-toast .lks-toast-glyph.err{color:var(--lks-err-ink)}
179
+ .lks-toast .lks-toast-glyph.info{color:var(--dsw-alias-state-business-primary)}
180
+ .lks-toast-action{border:none;background:none;color:var(--dsw-alias-state-business-primary);font-weight:600;font-size:13px;cursor:pointer;padding:4px 8px;border-radius:999px;flex:none}
181
+ .lks-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)}
182
+ .lks-toast-action:focus-visible,.lks-toast-close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
183
+ .lks-toast-close{border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;font-size:15px;line-height:1;padding:0;flex:none}
184
+ .lks-toast-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
185
+ @keyframes lks-toast-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
186
+ @keyframes lks-toast-out{to{opacity:0;transform:translateY(-6px)}}
187
+ @media (prefers-reduced-motion:reduce){.lks-toast{animation-duration:.01ms}}
188
+
189
+
190
+
191
+
192
+
193
+
194
+
195
+
196
+ /* the companion creature (P7): bottom-right of the panel; gentle bob idle,
197
+ bounce while talking, pop on celebration; reduced-motion = static (a11y). */
198
+ .lks-companion{position:absolute;right:18px;bottom:14px;z-index:20;width:56px;height:56px;cursor:pointer;filter:drop-shadow(0 4px 10px rgba(0,0,0,.18));animation:lks-comp-bob 3.2s ease-in-out infinite}
199
+ .lks-companion:focus-visible{outline:none;filter:drop-shadow(0 0 0 2px var(--dsw-alias-state-business-primary)) drop-shadow(0 4px 10px rgba(0,0,0,.18))}
200
+ .lks-companion.mood-talking{animation:lks-comp-talk .6s ease-in-out infinite}
201
+ .lks-companion.mood-celebrating{animation:lks-comp-pop .5s cubic-bezier(.34,1.56,.64,1) 3}
202
+ .lks-companion.mood-encouraging{animation:lks-comp-nudge 1.2s ease-in-out 2}
203
+ @keyframes lks-comp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
204
+ @keyframes lks-comp-talk{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-7px) scale(1.06)}}
205
+ @keyframes lks-comp-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}
206
+ @keyframes lks-comp-nudge{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px) rotate(-4deg)}75%{transform:translateX(5px) rotate(4deg)}}
207
+ @media (prefers-reduced-motion:reduce){.lks-companion{animation:none}}
208
+
209
+ /* P5: proposal banner (ConfirmCard semantics) + thinking row + narrow switch */
210
+ .lks-propbanner{flex:none;display:flex;align-items:center;gap:8px;margin:8px 12px 0;padding:8px 12px;border-radius:10px;background:var(--dsw-alias-state-business-tertiary);border:1px solid var(--dsw-alias-border-l2);font-size:12.5px;color:var(--dsw-alias-label-primary-bluish)}
211
+ .lks-propbanner .lks-propbanner-why{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
212
+ .lks14-thinking{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--dsw-alias-label-secondary);padding:2px 2px}
213
+ .lks14-switch{display:none;flex-direction:row;gap:2px;align-self:center;margin:8px 0 0;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px}
214
+ .lks14-switch-btn{border-radius:999px;padding:4px 16px;font-size:13px;color:var(--dsw-alias-label-secondary);background:transparent}
215
+ .lks14-switch-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
216
+ .lks14-switch-btn.on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font-weight:600}
217
+ @container (max-width: 900px){
218
+ .lks14-switch{display:flex}
219
+ .lks14-body{flex-direction:column}
220
+ .lks14-rail{flex:none;max-height:32vh;border-right:none;border-bottom:1px solid var(--dsw-alias-border-l1)}
221
+ .lks14-chat{min-width:0}
222
+ .lks14-note{flex:1 1 auto;min-height:0}
223
+ .lks14-body[data-pane='rail'] .lks14-chat,.lks14-body[data-pane='rail'] .lks14-note,
224
+ .lks14-body[data-pane='chat'] .lks14-rail,.lks14-body[data-pane='chat'] .lks14-note,
225
+ .lks14-body[data-pane='note'] .lks14-rail,.lks14-body[data-pane='note'] .lks14-chat{display:none}
226
+ .lks14-body[data-pane='rail'] .lks14-rail{display:flex;max-height:none;flex:1 1 auto}
227
+ .lks14-body[data-pane='note'] .lks14-note{display:flex}
228
+ }
229
+
230
+ /* rail search results panel (P4, upstream CourseSearchPanel) */
231
+ .lks14-searchpanel{margin:4px 0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
232
+ .lks14-searchrow{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;text-align:left;font:inherit;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--dsw-alias-border-l1)}
233
+ .lks14-searchrow:last-child{border-bottom:none}
234
+ .lks14-searchrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
235
+ .lks14-searchrow-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}
236
+ .lks14-searchrow-course{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
237
+ .lks14-searchrow-snip{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
238
+
239
+ /* review self-rating card (P3, upstream SelfRatingCard: three Memrise levels) */
240
+ .lks-ratecard{flex:none;display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-state-business-tertiary);padding:10px 12px;margin-bottom:8px}
241
+ .lks-ratecard-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary-bluish)}
242
+ .lks-ratecard-opts{display:flex;gap:6px}
243
+ .lks-ratecard-opt{flex:1;font:inherit;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:7px 10px;cursor:pointer}
244
+ .lks-ratecard-opt:hover{border-color:var(--dsw-alias-state-business-primary)}
245
+ .lks-ratecard-opt.again:hover{border-color:var(--dsw-alias-state-error-primary)}
246
+ .lks-ratecard-opt.best{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 86%,#000);color:#fff;border-color:transparent}
247
+ .lks-ratecard-opt.best:hover{filter:brightness(1.1)}
248
+ .lks-ratecard-opt:disabled{opacity:.6;cursor:default}
249
+ .lks14-dueitem{font:inherit;text-align:left}
250
+
251
+ /* selection-to-note (P2, upstream selection flow): the popover floats over
252
+ the prose; persisted highlights are soft amber marks. */
253
+ .lks14-prosewrap{position:relative}
254
+ .lks-quote-btn{position:absolute;z-index:12;display:flex;gap:2px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.16);transform:translateX(-50%)}
255
+ .lks-quote-btn button{border:none;background:none;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;font-weight:600;padding:6px 10px;cursor:pointer;border-radius:10px}
256
+ .lks-quote-btn button:hover{background:var(--dsw-alias-interactive-bg-hover)}
257
+ .lks-quote-btn button + button{border-left:1px solid var(--dsw-alias-border-l1)}
258
+ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border-bottom:2px solid var(--dsw-alias-state-warn-primary);padding:0 1px}
259
+
260
+ /* artifact cards (P1b, upstream artifacts port): chat column + notebook zones */
261
+ .lks-acard{flex:none;margin:2px 12px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:8px;overflow:hidden}
262
+ .lks-acard-head{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
263
+ .lks-acard-expand{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:2px 6px;border-radius:6px}
264
+ .lks-acard-expand:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
265
+ .lks-acard-table{border-collapse:collapse;display:block;max-width:100%;overflow-x:auto}
266
+ .lks-acard-table th,.lks-acard-table td{border:1px solid var(--dsw-alias-border-l2);padding:4px 9px;font-size:12.5px;text-align:left}
267
+ .lks-acard-table th{background:var(--dsw-alias-bg-layer-2)}
268
+ .lks-acard-code{background:var(--dsw-alias-markdown-code-block);border-radius:8px;padding:8px 0;overflow-x:auto;font-family:var(--dsw-font-markdown-code-block-small);font-size:12px;line-height:1.6;margin:0}
269
+ .lks-acard-line{display:block;white-space:pre}
270
+ .lks-acard-line i{display:inline-block;width:2.2em;color:var(--dsw-alias-label-tertiary);font-style:normal;text-align:right;padding-right:8px;user-select:none}
271
+ .lks-acard-notes{display:flex;flex-direction:column;gap:5px}
272
+ .lks-acard-note{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-business-primary);padding-left:8px}
273
+ .lks-acard-note b{color:var(--dsw-alias-label-primary);font-weight:600;margin-right:4px}
274
+ .lks-acard-diagram{overflow-x:auto;text-align:center}
275
+ .lks-acard-diagram svg{max-width:100%;height:auto}
276
+ .lks-acard-modal{position:fixed;inset:0;z-index:90;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:32px}
277
+ .lks-acard-modal-body{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;max-width:900px;max-height:100%;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}
278
+ .lks-acard-modal-title{display:flex;align-items:center;font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
279
+ .lks-acard-guess-prompt{font-size:13.5px;line-height:1.6;color:var(--dsw-alias-label-primary)}
280
+ .lks-acard-guess-opts{display:flex;gap:8px}
281
+ .lks-acard-guess-opt{flex:1;font:inherit;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;cursor:pointer}
282
+ .lks-acard-guess-opt:not(:disabled):hover{border-color:var(--dsw-alias-state-business-primary)}
283
+ .lks-acard-guess-opt.picked{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
284
+ .lks-acard-guess-wait{font-size:12px;color:var(--dsw-alias-label-tertiary)}
285
+ .lks-acard-stage{margin-top:14px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
286
+ .lks-acard-stage .lks-acard{margin:0}
287
+ .lks14-zone .lks-acard{margin:0 0 8px}
288
+ /* notebook tab badge (unseen artifacts) */
289
+ .lks-viewtab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;border-radius:8px;background:var(--dsw-alias-state-business-primary);color:#fff;font-size:10px;font-weight:700;margin-left:5px;padding:0 4px}
290
+
291
+ /* practice card (P1, upstream QuizArtifact port): chat column, below the stream */
292
+ .lks-qcard{flex:none;margin:2px 12px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:8px}
293
+ .lks-qcard-head{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
294
+ .lks-qcard-count{margin-left:auto;font-weight:400;color:var(--dsw-alias-label-tertiary)}
295
+ .lks-qcard-q{display:flex;flex-direction:column;gap:6px}
296
+ .lks-qcard-prompt{font-size:13.5px;line-height:1.55;color:var(--dsw-alias-label-primary)}
297
+ .lks-qcard-opts{display:flex;flex-direction:column;gap:4px}
298
+ .lks-qcard-opt{text-align:left;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:6px 10px;cursor:pointer}
299
+ .lks-qcard-opt:not(:disabled):hover{border-color:var(--dsw-alias-state-business-primary)}
300
+ .lks-qcard-opt:disabled{cursor:default}
301
+ .lks-qcard-opt.right{border-color:var(--dsw-alias-state-success-primary);color:var(--lks-ok-ink)}
302
+ .lks-qcard-opt.wrong{border-color:var(--dsw-alias-state-error-primary);color:var(--lks-err-ink)}
303
+ .lks-qcard-opt.dim{opacity:.75}
304
+ .lks-qcard-expl{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-business-primary);padding-left:8px}
305
+ .lks-qcard-next{margin-top:2px;border:none;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font:inherit;font-size:12.5px;font-weight:600;border-radius:8px;padding:5px 14px;cursor:pointer}
306
+ .lks-qcard-next:hover{filter:brightness(1.1)}
307
+ .lks-qcard-review .lks-qcard-q.review{padding:6px 0;border-bottom:1px dashed var(--dsw-alias-border-l1)}
308
+ .lks-qcard-review .lks-qcard-q.review.wrong .lks-qcard-prompt{color:var(--lks-err-ink)}
309
+ .lks-qcard-ans{font-size:12.5px;color:var(--dsw-alias-label-secondary)}
310
+ .lks-qcard-actions{display:flex;flex-wrap:wrap;gap:6px}
311
+ .lks-qcard-action{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:12.5px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:4px 12px;cursor:pointer}
312
+ .lks-qcard-action:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
313
+
155
314
  /* note cards (shared: panel notebook + settings-era shapes) */
156
315
  .lks-note{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 14px;margin-bottom:8px}
157
316
  .lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
@@ -346,6 +505,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
346
505
  fill: "currentColor"
347
506
  }));
348
507
  }
508
+ /** IconWarningOutline16 */
509
+ function IconWarningOutline16({ size = 16, className }) {
510
+ return (0, react.createElement)("svg", {
511
+ width: size,
512
+ height: size,
513
+ className,
514
+ viewBox: "0 0 16 16",
515
+ fill: "none",
516
+ xmlns: "http://www.w3.org/2000/svg"
517
+ }, (0, react.createElement)("path", {
518
+ d: "M6.3002 3.32843L7.69986 3.32843L7.69986 7.79657H6.3002L6.3002 3.32843Z",
519
+ fill: "currentColor"
520
+ }), (0, react.createElement)("path", {
521
+ d: "M6.3002 9.01935H7.69986V10.6711H6.3002V9.01935Z",
522
+ fill: "currentColor"
523
+ }), (0, react.createElement)("path", {
524
+ d: "M12.6328 6.99976C12.6328 3.88874 10.111 1.36694 7 1.36694C3.88899 1.36695 1.3672 3.88875 1.36719 6.99976C1.36719 10.1108 3.88899 12.6326 7 12.6326C10.111 12.6326 12.6328 10.1108 12.6328 6.99976ZM13.8582 6.99976C13.8582 10.7873 10.7876 13.8579 7 13.8579C3.21244 13.8579 0.141846 10.7873 0.141846 6.99976C0.141857 3.2122 3.21245 0.141612 7 0.141602C10.7876 0.141602 13.8581 3.21219 13.8582 6.99976Z",
525
+ fill: "currentColor"
526
+ }));
527
+ }
349
528
  /**
350
529
  * Plugin-original companions for learning-domain glyphs the host set does not
351
530
  * carry (lock/star/book/crown/bolt/power/pin/flame) — authored here in the
@@ -556,6 +735,30 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
556
735
  });
557
736
  this.refresh();
558
737
  }
738
+ /** Save a learner selection as a record-zone note (the highlight anchor is the quote). */
739
+ async addUserNote(lessonId, quote) {
740
+ await fetchJson("/lookatstudy/api/note/user", {
741
+ method: "POST",
742
+ headers: { "content-type": "application/json" },
743
+ body: JSON.stringify({
744
+ lessonId,
745
+ quote
746
+ })
747
+ });
748
+ this.refresh();
749
+ }
750
+ /** Record one SM-2 self-rating (1 again / 4 remembered / 5 mastered). */
751
+ async recordReview(lessonId, quality) {
752
+ await fetchJson("/lookatstudy/api/review", {
753
+ method: "POST",
754
+ headers: { "content-type": "application/json" },
755
+ body: JSON.stringify({
756
+ lessonId,
757
+ quality
758
+ })
759
+ });
760
+ this.refresh();
761
+ }
559
762
  /** Delete one notebook entry from a lesson's Cornell zones. */
560
763
  async deleteNote(lessonId, noteId) {
561
764
  await fetchJson("/lookatstudy/api/note/delete", {
@@ -643,6 +846,8 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
643
846
  searchLessons: studyStore.searchLessons.bind(studyStore),
644
847
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
645
848
  deleteNote: studyStore.deleteNote.bind(studyStore),
849
+ addUserNote: studyStore.addUserNote.bind(studyStore),
850
+ recordReview: studyStore.recordReview.bind(studyStore),
646
851
  bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
647
852
  };
648
853
  }
@@ -822,6 +1027,12 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
822
1027
  }
823
1028
  //#endregion
824
1029
  //#region src/client/enhance.ts
1030
+ var enhance_exports = /* @__PURE__ */ __exportAll({
1031
+ enhanceCode: () => enhanceCode,
1032
+ enhanceDiagrams: () => enhanceDiagrams,
1033
+ enhanceMath: () => enhanceMath,
1034
+ enhanceRendered: () => enhanceRendered
1035
+ });
825
1036
  /**
826
1037
  * Post-render blackboard enhancement (SPEC Phase 2): math (KaTeX), code
827
1038
  * highlighting (shiki), and mermaid diagrams — every dependency loaded from a
@@ -1470,6 +1681,68 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1470
1681
  "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1471
1682
  "rail.empty.button": "导入",
1472
1683
  "composer.send": "发送",
1684
+ "quiz.card.title": "练习",
1685
+ "quiz.progress": "{cur}/{total}",
1686
+ "quiz.next": "下一题",
1687
+ "quiz.finish": "看成绩单",
1688
+ "quiz.score": "成绩:{correct}/{total}",
1689
+ "quiz.right": "答对了",
1690
+ "quiz.wrong": "答错了",
1691
+ "quiz.youChose": "你选了",
1692
+ "quiz.answer": "正确答案",
1693
+ "quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
1694
+ "quiz.action.explain-wrong": "讲讲错题",
1695
+ "quiz.action.retry": "再来一组",
1696
+ "quiz.action.go-deeper": "深入原理",
1697
+ "quiz.action.mark-mastered": "标记我掌握了",
1698
+ "quiz.action.markMastered.hint": "会让导师出综合题检验,通过即标记掌握",
1699
+ "quiz.action.next-topic": "下一个知识点",
1700
+ "artifact.lines": "第 {from}-{to} 行:",
1701
+ "artifact.expand": "放大查看",
1702
+ "artifact.guess.title": "猜一猜",
1703
+ "artifact.guess.pick": "我选「{label}」",
1704
+ "artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
1705
+ "artifact.sedimented": "有新的学习产物沉淀进笔记",
1706
+ "zone.artifacts": "AI 产物",
1707
+ "note.quote.ask": "提问这段",
1708
+ "note.quote.save": "加到笔记",
1709
+ "note.quote.template": "请讲解这段:「{text}」",
1710
+ "note.saved": "已存入记录区笔记",
1711
+ "review.rate.title": "复习自评——这课现在记得多牢?",
1712
+ "review.again": "再来一次",
1713
+ "review.remembered": "记住了",
1714
+ "review.mastered": "完全掌握",
1715
+ "review.done.good": "已安排下次复习",
1716
+ "review.done.again": "明天再来一遍",
1717
+ "review.jump": "点开这一课去复习",
1718
+ "review.nudge": "有 {n} 课到了复习时间,先回来练一遍?",
1719
+ "review.nudge.go": "去复习",
1720
+ "rail.search.jump": "打开这一课",
1721
+ "proposal.banner": "《{lesson}》可以提前毕业了",
1722
+ "proposal.accept": "接受",
1723
+ "proposal.decline": "再练练",
1724
+ "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
1725
+ "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
1726
+ "tutor.thinking": "导师思考中…",
1727
+ "pane.rail": "课程",
1728
+ "pane.chat": "对话",
1729
+ "pane.note": "黑板",
1730
+ "companion.poke": "戳一戳伴学伙伴",
1731
+ "companion.poked": "伴学伙伴向你眨了眨眼",
1732
+ "companion.form.ember": "小焰",
1733
+ "companion.form.frost": "霜绒",
1734
+ "companion.form.moss": "苔芽",
1735
+ "companion.form.star": "星尘",
1736
+ "companion.form.ink": "墨墨",
1737
+ "settings.companion": "伴学伙伴",
1738
+ "settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
1739
+ "quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
1740
+ "quiz.msg.retry": "再来一组类似的题巩固一下。",
1741
+ "quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
1742
+ "quiz.msg.mark-mastered": "这课我觉得掌握了,帮我确认一下——出个综合题检验,通过了就标记为掌握。",
1743
+ "quiz.msg.next-topic": "进入下一个知识点。",
1744
+ "toast.region": "通知",
1745
+ "toast.close": "关闭",
1473
1746
  "composer.busy": "导师正在回复…",
1474
1747
  "rail.empty.demo": "导入示例课程",
1475
1748
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -1608,6 +1881,68 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1608
1881
  "rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
1609
1882
  "rail.empty.button": "Import",
1610
1883
  "composer.send": "Send",
1884
+ "quiz.card.title": "Practice",
1885
+ "quiz.progress": "{cur}/{total}",
1886
+ "quiz.next": "Next",
1887
+ "quiz.finish": "See score",
1888
+ "quiz.score": "Score: {correct}/{total}",
1889
+ "quiz.right": "Correct",
1890
+ "quiz.wrong": "Wrong",
1891
+ "quiz.youChose": "You chose",
1892
+ "quiz.answer": "Answer",
1893
+ "quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
1894
+ "quiz.action.explain-wrong": "Explain my wrong answers",
1895
+ "quiz.action.retry": "Another set",
1896
+ "quiz.action.go-deeper": "Go deeper",
1897
+ "quiz.action.mark-mastered": "Mark me as mastered",
1898
+ "quiz.action.markMastered.hint": "Asks the tutor to verify with a final question, then marks mastery",
1899
+ "quiz.action.next-topic": "Next topic",
1900
+ "artifact.lines": "Lines {from}-{to}: ",
1901
+ "artifact.expand": "Expand",
1902
+ "artifact.guess.title": "Take a guess",
1903
+ "artifact.guess.pick": "I pick \"{label}\"",
1904
+ "artifact.guess.wait": "Your instinct is noted — the tutor reveals next turn",
1905
+ "artifact.sedimented": "A new artifact settled into the notebook",
1906
+ "zone.artifacts": "AI artifacts",
1907
+ "note.quote.ask": "Ask about this",
1908
+ "note.quote.save": "Add note",
1909
+ "note.quote.template": "Please explain this passage: \"{text}\"",
1910
+ "note.saved": "Saved to the record zone",
1911
+ "review.rate.title": "Review self-check — how well do you remember?",
1912
+ "review.again": "Again",
1913
+ "review.remembered": "Remembered",
1914
+ "review.mastered": "Mastered",
1915
+ "review.done.good": "Next review scheduled",
1916
+ "review.done.again": "Back again tomorrow",
1917
+ "review.jump": "Open this lesson to review",
1918
+ "review.nudge": "{n} lessons are due for review — come practice first?",
1919
+ "review.nudge.go": "Review now",
1920
+ "rail.search.jump": "Open this lesson",
1921
+ "proposal.banner": "{lesson} is ready to graduate early",
1922
+ "proposal.accept": "Accept",
1923
+ "proposal.decline": "Keep practicing",
1924
+ "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
1925
+ "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
1926
+ "tutor.thinking": "The tutor is thinking…",
1927
+ "pane.rail": "Course",
1928
+ "pane.chat": "Chat",
1929
+ "pane.note": "Notebook",
1930
+ "companion.poke": "Poke the companion",
1931
+ "companion.poked": "The companion winks at you",
1932
+ "companion.form.ember": "Ember",
1933
+ "companion.form.frost": "Frost",
1934
+ "companion.form.moss": "Moss",
1935
+ "companion.form.star": "Stardust",
1936
+ "companion.form.ink": "Ink",
1937
+ "settings.companion": "Companion",
1938
+ "settings.companion.hint": "Choose the companion creature in the study panel corner.",
1939
+ "quiz.msg.explain-wrong": "I got some questions wrong — walk me through why and the right line of thinking.",
1940
+ "quiz.msg.retry": "Give me another similar set to consolidate.",
1941
+ "quiz.msg.go-deeper": "I did well on this set — go deeper into the principles and common confusions.",
1942
+ "quiz.msg.mark-mastered": "I feel I have mastered this lesson — verify me with a final question and mark it mastered if I pass.",
1943
+ "quiz.msg.next-topic": "Move on to the next topic.",
1944
+ "toast.region": "Notifications",
1945
+ "toast.close": "Close",
1611
1946
  "composer.busy": "The tutor is replying…",
1612
1947
  "rail.empty.demo": "Import the demo course",
1613
1948
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -2210,6 +2545,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2210
2545
  if (event === void 0) continue;
2211
2546
  if (entry.type === "event") {
2212
2547
  if (event.type === "user/message") {
2548
+ if (event.data?.source?.kind !== "user") continue;
2213
2549
  const text = userText(event);
2214
2550
  if (text !== "") rows.push({
2215
2551
  key: `u${event.seq}`,
@@ -2355,6 +2691,782 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2355
2691
  }
2356
2692
  };
2357
2693
  //#endregion
2694
+ //#region src/client/toast.ts
2695
+ /** Per-severity defaults (upstream Toast.tsx): error 6s, warning 5s, rest 4s. */
2696
+ const SEVERITY_DURATION = {
2697
+ default: 4e3,
2698
+ success: 4e3,
2699
+ info: 4e3,
2700
+ warning: 5e3,
2701
+ error: 6e3
2702
+ };
2703
+ const browserScheduler = (fn, ms) => {
2704
+ const handle = window.setTimeout(fn, ms);
2705
+ return () => {
2706
+ window.clearTimeout(handle);
2707
+ };
2708
+ };
2709
+ var ToastStore = class {
2710
+ items = [];
2711
+ nextId = 0;
2712
+ listeners = /* @__PURE__ */ new Set();
2713
+ cancellers = /* @__PURE__ */ new Map();
2714
+ scheduler;
2715
+ constructor(scheduler = browserScheduler) {
2716
+ this.scheduler = scheduler;
2717
+ }
2718
+ getSnapshot() {
2719
+ return this.items;
2720
+ }
2721
+ subscribe(listener) {
2722
+ this.listeners.add(listener);
2723
+ return () => {
2724
+ this.listeners.delete(listener);
2725
+ };
2726
+ }
2727
+ emit() {
2728
+ for (const listener of this.listeners) listener();
2729
+ }
2730
+ /** Enqueue one toast; its auto-exit is scheduled at its duration. */
2731
+ show(message, opts) {
2732
+ const severity = opts?.severity ?? "default";
2733
+ const id = ++this.nextId;
2734
+ const item = {
2735
+ id,
2736
+ message,
2737
+ severity,
2738
+ duration: opts?.duration ?? SEVERITY_DURATION[severity],
2739
+ action: opts?.action,
2740
+ exiting: false
2741
+ };
2742
+ this.items = [...this.items, item];
2743
+ this.cancellers.set(id, this.scheduler(() => this.startExit(id), item.duration));
2744
+ this.emit();
2745
+ return id;
2746
+ }
2747
+ /** Begin the exit (dismiss click or the duration timer); cancels the timer. */
2748
+ startExit(id) {
2749
+ const cancel = this.cancellers.get(id);
2750
+ if (cancel !== void 0) {
2751
+ cancel();
2752
+ this.cancellers.delete(id);
2753
+ }
2754
+ this.items = this.items.map((t) => t.id === id ? {
2755
+ ...t,
2756
+ exiting: true
2757
+ } : t);
2758
+ this.emit();
2759
+ }
2760
+ /** Actually remove — called by the view when the exit animation ends. */
2761
+ finish(id) {
2762
+ this.items = this.items.filter((t) => t.id !== id);
2763
+ this.cancellers.delete(id);
2764
+ this.emit();
2765
+ }
2766
+ /** Drop everything now (the panel unmounted — nothing outlives the panel). */
2767
+ clear() {
2768
+ for (const cancel of this.cancellers.values()) cancel();
2769
+ this.cancellers.clear();
2770
+ this.items = [];
2771
+ this.emit();
2772
+ }
2773
+ };
2774
+ /** The panel-wide singleton stack; consumers call showStudyToast. */
2775
+ const toastStore = new ToastStore();
2776
+ function showStudyToast(message, opts) {
2777
+ return toastStore.show(message, opts);
2778
+ }
2779
+ /**
2780
+ * The exits after one finished card — never fewer than two (the anti-dead-end
2781
+ * promise). Wrong answers → patch the hole first; perfect + near-mastered →
2782
+ * offer the mastery exit; perfect + already graduated → move on.
2783
+ * Pure port of upstream getPostQuizActions.
2784
+ */
2785
+ function getPostQuizActions(score, mastery) {
2786
+ const { correct, total } = score;
2787
+ const allCorrect = total > 0 && correct === total;
2788
+ const hasWrong = total > 0 && correct < total;
2789
+ const alreadyMastered = mastery !== null && mastery >= .9;
2790
+ const nearMastered = mastery !== null && mastery >= .85;
2791
+ if (hasWrong) return [{
2792
+ id: "explain-wrong",
2793
+ advancesMastery: false
2794
+ }, {
2795
+ id: "retry",
2796
+ advancesMastery: false
2797
+ }];
2798
+ if (allCorrect && alreadyMastered) return [{
2799
+ id: "next-topic",
2800
+ advancesMastery: false
2801
+ }, {
2802
+ id: "go-deeper",
2803
+ advancesMastery: false
2804
+ }];
2805
+ if (allCorrect && nearMastered) return [{
2806
+ id: "mark-mastered",
2807
+ advancesMastery: true
2808
+ }, {
2809
+ id: "go-deeper",
2810
+ advancesMastery: false
2811
+ }];
2812
+ if (allCorrect) return [{
2813
+ id: "go-deeper",
2814
+ advancesMastery: false
2815
+ }, {
2816
+ id: "retry",
2817
+ advancesMastery: false
2818
+ }];
2819
+ return [{
2820
+ id: "explain-wrong",
2821
+ advancesMastery: false
2822
+ }, {
2823
+ id: "retry",
2824
+ advancesMastery: false
2825
+ }];
2826
+ }
2827
+ /** localStorage key (upstream quizProgressKey semantics: per artifact). */
2828
+ function quizProgressKey(lessonId, artifactId) {
2829
+ return `dsh-plugin-lookatstudy:quiz:${lessonId}:${artifactId}`;
2830
+ }
2831
+ function loadQuizProgress(lessonId, artifactId, questionCount, storage = localStorage) {
2832
+ try {
2833
+ const raw = storage.getItem(quizProgressKey(lessonId, artifactId));
2834
+ if (raw === null) return {
2835
+ answers: Array(questionCount).fill(-1),
2836
+ done: false
2837
+ };
2838
+ const parsed = JSON.parse(raw);
2839
+ const answers = Array.isArray(parsed.answers) ? parsed.answers.map((a) => typeof a === "number" ? a : -1) : [];
2840
+ const padded = Array(questionCount).fill(-1);
2841
+ for (let i = 0; i < Math.min(answers.length, questionCount); i += 1) padded[i] = answers[i];
2842
+ return {
2843
+ answers: padded,
2844
+ done: parsed.done === true
2845
+ };
2846
+ } catch {
2847
+ return {
2848
+ answers: Array(questionCount).fill(-1),
2849
+ done: false
2850
+ };
2851
+ }
2852
+ }
2853
+ function saveQuizProgress(lessonId, artifactId, progress, storage = localStorage) {
2854
+ try {
2855
+ storage.setItem(quizProgressKey(lessonId, artifactId), JSON.stringify(progress));
2856
+ } catch {}
2857
+ }
2858
+ /** The score over recorded answers (pure). */
2859
+ function quizScore(data, answers) {
2860
+ let correct = 0;
2861
+ let total = 0;
2862
+ data.questions.forEach((q, i) => {
2863
+ const choice = answers[i];
2864
+ if (choice === void 0 || choice < 0) return;
2865
+ total += 1;
2866
+ if (choice === q.answer) correct += 1;
2867
+ });
2868
+ return {
2869
+ correct,
2870
+ total
2871
+ };
2872
+ }
2873
+ const ACTION_ICONS = {
2874
+ "explain-wrong": IconBookFill16,
2875
+ "retry": IconRefreshOutline16,
2876
+ "go-deeper": IconBoltFill16,
2877
+ "mark-mastered": IconCrownFill16,
2878
+ "next-topic": IconStarFill16
2879
+ };
2880
+ /**
2881
+ * The card: one question at a time, click-to-answer (instant local judge +
2882
+ * explanation reveal — the cursor is explicit, so the reveal is never skipped),
2883
+ * progress persisted per artifact; the score screen is an explicit step, and
2884
+ * on first arrival its completion hook goes to the tutor exactly once.
2885
+ */
2886
+ function QuizCard({ lessonId, artifactId, data, masteryPct, send, onFinished }) {
2887
+ const questions = data.questions;
2888
+ const [progress, setProgress] = (0, react.useState)(() => loadQuizProgress(lessonId, artifactId, questions.length));
2889
+ const [cursor, setCursor] = (0, react.useState)(0);
2890
+ const [showScore, setShowScore] = (0, react.useState)(false);
2891
+ const [hookSent, setHookSent] = (0, react.useState)(false);
2892
+ (0, react.useEffect)(() => {
2893
+ const next = loadQuizProgress(lessonId, artifactId, questions.length);
2894
+ setProgress(next);
2895
+ setShowScore(next.done);
2896
+ const firstUnanswered = next.answers.findIndex((a) => a < 0);
2897
+ setCursor(firstUnanswered === -1 ? questions.length - 1 : firstUnanswered);
2898
+ setHookSent(false);
2899
+ }, [
2900
+ lessonId,
2901
+ artifactId,
2902
+ questions.length
2903
+ ]);
2904
+ const allAnswered = progress.answers.every((a) => a >= 0);
2905
+ const score = quizScore(data, progress.answers);
2906
+ const mastery = masteryPct === null ? null : masteryPct / 100;
2907
+ (0, react.useEffect)(() => {
2908
+ if (!showScore || !allAnswered || hookSent) return;
2909
+ setHookSent(true);
2910
+ onFinished?.(score.correct === score.total);
2911
+ const next = {
2912
+ answers: [...progress.answers],
2913
+ done: true
2914
+ };
2915
+ setProgress(next);
2916
+ saveQuizProgress(lessonId, artifactId, next);
2917
+ send(tr("quiz.hook", {
2918
+ correct: score.correct,
2919
+ total: score.total
2920
+ }));
2921
+ }, [
2922
+ showScore,
2923
+ allAnswered,
2924
+ hookSent,
2925
+ lessonId,
2926
+ artifactId,
2927
+ progress.answers,
2928
+ score.correct,
2929
+ score.total,
2930
+ send
2931
+ ]);
2932
+ const answer = (choice) => {
2933
+ const next = {
2934
+ answers: [...progress.answers],
2935
+ done: progress.done
2936
+ };
2937
+ next.answers[cursor] = choice;
2938
+ setProgress(next);
2939
+ saveQuizProgress(lessonId, artifactId, next);
2940
+ };
2941
+ if (showScore && allAnswered) {
2942
+ const actions = getPostQuizActions(score, mastery);
2943
+ return (0, react.createElement)("div", {
2944
+ className: "lks-qcard",
2945
+ "data-lks-quiz": artifactId
2946
+ }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconStarFill16, { size: 14 }), tr("quiz.score", {
2947
+ correct: score.correct,
2948
+ total: score.total
2949
+ })), (0, react.createElement)("div", { className: "lks-qcard-review" }, ...questions.map((q, i) => {
2950
+ const choice = progress.answers[i] ?? -1;
2951
+ const right = choice === q.answer;
2952
+ return (0, react.createElement)("div", {
2953
+ key: i,
2954
+ className: `lks-qcard-q review${right ? "" : " wrong"}`
2955
+ }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, `${i + 1}. ${q.prompt}`), (0, react.createElement)("div", { className: "lks-qcard-ans" }, right ? "" : `${tr("quiz.youChose")}:${q.options[choice] ?? "—"} · `, `${tr("quiz.answer")}:${q.options[q.answer] ?? "—"}`), (0, react.createElement)("div", { className: "lks-qcard-expl" }, q.explanation));
2956
+ })), (0, react.createElement)("div", { className: "lks-qcard-actions" }, ...actions.map((a) => (0, react.createElement)("button", {
2957
+ key: a.id,
2958
+ className: "lks-qcard-action",
2959
+ title: a.advancesMastery ? tr("quiz.action.markMastered.hint") : void 0,
2960
+ onClick: () => {
2961
+ send(tr(`quiz.msg.${a.id}`));
2962
+ }
2963
+ }, (0, react.createElement)(ACTION_ICONS[a.id], { size: 13 }), tr(`quiz.action.${a.id}`)))));
2964
+ }
2965
+ const q = questions[cursor];
2966
+ const chosen = progress.answers[cursor] ?? -1;
2967
+ const options = q.options.map((opt, i) => {
2968
+ const picked = chosen === i;
2969
+ const isRight = i === q.answer;
2970
+ return (0, react.createElement)("button", {
2971
+ key: i,
2972
+ className: `lks-qcard-opt${chosen < 0 ? "" : picked ? isRight ? " right" : " wrong" : isRight ? " right dim" : ""}`,
2973
+ disabled: chosen >= 0,
2974
+ onClick: () => {
2975
+ answer(i);
2976
+ }
2977
+ }, opt);
2978
+ });
2979
+ const explanation = chosen < 0 ? null : (0, react.createElement)("div", { className: "lks-qcard-expl" }, `${chosen === q.answer ? tr("quiz.right") : tr("quiz.wrong")} — ${q.explanation}`, (0, react.createElement)("div", { style: { marginTop: "6px" } }, (0, react.createElement)("button", {
2980
+ className: "lks-qcard-next",
2981
+ onClick: () => {
2982
+ if (cursor + 1 < questions.length) setCursor(cursor + 1);
2983
+ else setShowScore(true);
2984
+ }
2985
+ }, cursor + 1 < questions.length ? tr("quiz.next") : tr("quiz.finish"))));
2986
+ return (0, react.createElement)("div", {
2987
+ className: "lks-qcard",
2988
+ "data-lks-quiz": artifactId
2989
+ }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconBookFill16, { size: 14 }), data.title ?? tr("quiz.card.title"), (0, react.createElement)("span", { className: "lks-qcard-count" }, tr("quiz.progress", {
2990
+ cur: cursor + 1,
2991
+ total: questions.length
2992
+ }))), (0, react.createElement)("div", { className: "lks-qcard-q" }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, q.prompt), (0, react.createElement)("div", { className: "lks-qcard-opts" }, ...options), explanation));
2993
+ }
2994
+ //#endregion
2995
+ //#region src/client/artifact-cards.tsx
2996
+ /**
2997
+ * The artifact cards (0.15.0 P1b, upstream artifacts port): compare tables,
2998
+ * code walkthroughs, mermaid diagrams (with the expand modal), and the
2999
+ * opening guess — plus the seen-tracking that feeds the notebook badge and
3000
+ * the "new artifact" toast. Rendering is lazy where it is heavy (mermaid via
3001
+ * the enhance pipeline); everything else is plain dsw-token DOM.
3002
+ * @module dsh-plugin-lookatstudy/client/artifact-cards
3003
+ */
3004
+ /** localStorage key for the artifact ids the learner has already seen. */
3005
+ function seenArtifactsKey(lessonId) {
3006
+ return `dsh-plugin-lookatstudy:seen-artifacts:${lessonId}`;
3007
+ }
3008
+ /** Which artifact ids are new (not yet seen); pure over the stored set. */
3009
+ function unseenArtifacts(lessonId, artifacts, storage = localStorage) {
3010
+ let seen;
3011
+ try {
3012
+ const raw = storage.getItem(seenArtifactsKey(lessonId));
3013
+ seen = new Set(Array.isArray(JSON.parse(raw ?? "[]")) ? JSON.parse(raw ?? "[]") : []);
3014
+ } catch {
3015
+ seen = /* @__PURE__ */ new Set();
3016
+ }
3017
+ return artifacts.filter((a) => !seen.has(a.id)).map((a) => a.id);
3018
+ }
3019
+ /** Mark artifact ids seen (opening the notebook counts as seeing them). */
3020
+ function markArtifactsSeen(lessonId, ids, storage = localStorage) {
3021
+ try {
3022
+ let seen = [];
3023
+ try {
3024
+ const parsed = JSON.parse(storage.getItem(seenArtifactsKey(lessonId)) ?? "[]");
3025
+ if (Array.isArray(parsed)) seen = parsed.filter((x) => typeof x === "string");
3026
+ } catch {}
3027
+ storage.setItem(seenArtifactsKey(lessonId), JSON.stringify([.../* @__PURE__ */ new Set([...seen, ...ids])]));
3028
+ } catch {}
3029
+ }
3030
+ /** The cards render mermaid through the shared CDN pipeline
3031
+ * (enhanceDiagrams consumes pre > code.lang-mermaid blocks). */
3032
+ async function renderMermaidInto(el, code, onError) {
3033
+ try {
3034
+ const { enhanceDiagrams } = await Promise.resolve().then(() => enhance_exports);
3035
+ el.textContent = "";
3036
+ const pre = document.createElement("pre");
3037
+ const codeEl = document.createElement("code");
3038
+ codeEl.className = "lang-mermaid";
3039
+ codeEl.textContent = code;
3040
+ pre.appendChild(codeEl);
3041
+ el.appendChild(pre);
3042
+ if (await enhanceDiagrams(el) === 0) onError();
3043
+ } catch {
3044
+ onError();
3045
+ }
3046
+ }
3047
+ /** Compare table card (headers + rows). */
3048
+ function CompareTableCard({ artifact }) {
3049
+ const headers = artifact.data.headers ?? [];
3050
+ const rows = artifact.data.rows ?? [];
3051
+ return (0, react.createElement)("div", {
3052
+ className: "lks-acard",
3053
+ "data-lks-artifact": artifact.id
3054
+ }, (0, react.createElement)("div", { className: "lks-acard-head" }, artifact.title), (0, react.createElement)("table", { className: "lks-acard-table" }, (0, react.createElement)("thead", null, (0, react.createElement)("tr", null, ...headers.map((h, i) => (0, react.createElement)("th", { key: i }, h)))), (0, react.createElement)("tbody", null, ...rows.map((row, r) => (0, react.createElement)("tr", { key: r }, ...row.map((c, i) => (0, react.createElement)("td", { key: i }, c)))))));
3055
+ }
3056
+ /** Code walkthrough card: line-numbered code + per-segment notes. */
3057
+ function CodeWalkthroughCard({ artifact }) {
3058
+ const language = typeof artifact.data.language === "string" ? artifact.data.language : "text";
3059
+ const code = typeof artifact.data.code === "string" ? artifact.data.code : "";
3060
+ const annotations = artifact.data.annotations ?? [];
3061
+ const lines = code.split("\n");
3062
+ return (0, react.createElement)("div", {
3063
+ className: "lks-acard",
3064
+ "data-lks-artifact": artifact.id
3065
+ }, (0, react.createElement)("div", { className: "lks-acard-head" }, `${artifact.title} · ${language}`), (0, react.createElement)("pre", { className: "lks-acard-code" }, ...lines.map((line, i) => (0, react.createElement)("span", {
3066
+ key: i,
3067
+ className: "lks-acard-line"
3068
+ }, (0, react.createElement)("i", null, String(i + 1)), line, "\n"))), (0, react.createElement)("div", { className: "lks-acard-notes" }, ...annotations.map((a, i) => (0, react.createElement)("div", {
3069
+ key: i,
3070
+ className: "lks-acard-note"
3071
+ }, (0, react.createElement)("b", null, `${tr("artifact.lines", {
3072
+ from: a.lineStart,
3073
+ to: a.lineEnd
3074
+ })}`), a.note))));
3075
+ }
3076
+ /** Mermaid diagram card with an expand modal (upstream DiagramViewerModal). */
3077
+ function DiagramCard({ artifact }) {
3078
+ const mermaid = typeof artifact.data.mermaid === "string" ? artifact.data.mermaid : "";
3079
+ const bodyRef = (0, react.useRef)(null);
3080
+ const [failed, setFailed] = (0, react.useState)(false);
3081
+ const [expanded, setExpanded] = (0, react.useState)(false);
3082
+ (0, react.useEffect)(() => {
3083
+ if (bodyRef.current === null) return;
3084
+ renderMermaidInto(bodyRef.current, mermaid, () => {
3085
+ setFailed(true);
3086
+ });
3087
+ }, [mermaid]);
3088
+ const body = failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", {
3089
+ className: "lks-acard-diagram",
3090
+ ref: bodyRef
3091
+ });
3092
+ return (0, react.createElement)("div", {
3093
+ className: "lks-acard",
3094
+ "data-lks-artifact": artifact.id
3095
+ }, (0, react.createElement)("div", { className: "lks-acard-head" }, artifact.title, (0, react.createElement)("button", {
3096
+ className: "lks-acard-expand",
3097
+ title: tr("artifact.expand"),
3098
+ onClick: () => {
3099
+ setExpanded(true);
3100
+ }
3101
+ }, "⤢")), body, expanded ? (0, react.createElement)("div", {
3102
+ className: "lks-acard-modal",
3103
+ onClick: () => {
3104
+ setExpanded(false);
3105
+ }
3106
+ }, (0, react.createElement)("div", {
3107
+ className: "lks-acard-modal-body",
3108
+ onClick: (e) => {
3109
+ e.stopPropagation();
3110
+ }
3111
+ }, (0, react.createElement)("div", { className: "lks-acard-modal-title" }, artifact.title, (0, react.createElement)("button", {
3112
+ className: "lks-acard-expand",
3113
+ onClick: () => {
3114
+ setExpanded(false);
3115
+ }
3116
+ }, "×")), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", { ref: (el) => {
3117
+ if (el !== null) renderMermaidInto(el, mermaid, () => {
3118
+ setFailed(true);
3119
+ });
3120
+ } }))) : null);
3121
+ }
3122
+ /** The opening guess: two big options, click = pick (sent to the tutor, who
3123
+ * reveals next turn); unscored — no right/wrong language anywhere. */
3124
+ function GuessCard({ artifact, send }) {
3125
+ const prompt = typeof artifact.data.prompt === "string" ? artifact.data.prompt : "";
3126
+ const options = artifact.data.options ?? [];
3127
+ const [picked, setPicked] = (0, react.useState)(null);
3128
+ (0, react.useEffect)(() => {
3129
+ setPicked(null);
3130
+ }, [artifact.id]);
3131
+ return (0, react.createElement)("div", {
3132
+ className: "lks-acard lks-acard-guess",
3133
+ "data-lks-artifact": artifact.id
3134
+ }, (0, react.createElement)("div", { className: "lks-acard-head" }, tr("artifact.guess.title")), (0, react.createElement)("div", { className: "lks-acard-guess-prompt" }, prompt), (0, react.createElement)("div", { className: "lks-acard-guess-opts" }, ...options.map((opt) => (0, react.createElement)("button", {
3135
+ key: opt.id,
3136
+ className: `lks-acard-guess-opt${picked === opt.id ? " picked" : ""}`,
3137
+ disabled: picked !== null,
3138
+ onClick: () => {
3139
+ setPicked(opt.id);
3140
+ send(tr("artifact.guess.pick", { label: opt.label }));
3141
+ }
3142
+ }, opt.label))), picked !== null ? (0, react.createElement)("div", { className: "lks-acard-guess-wait" }, tr("artifact.guess.wait")) : null);
3143
+ }
3144
+ /** Dispatch one artifact row to its card. */
3145
+ function ArtifactCard({ artifact, send }) {
3146
+ switch (artifact.artifactType) {
3147
+ case "compare_table": return (0, react.createElement)(CompareTableCard, { artifact });
3148
+ case "code_walkthrough": return (0, react.createElement)(CodeWalkthroughCard, { artifact });
3149
+ case "diagram": return (0, react.createElement)(DiagramCard, { artifact });
3150
+ case "guess": return (0, react.createElement)(GuessCard, {
3151
+ artifact,
3152
+ send
3153
+ });
3154
+ default: return null;
3155
+ }
3156
+ }
3157
+ //#endregion
3158
+ //#region src/client/companion.tsx
3159
+ /**
3160
+ * The companion creature — the upstream 伴学生物's minimal honest DOM port
3161
+ * (0.15.0 P7): a small being in the panel's bottom-right corner with five
3162
+ * forms, a pure mood machine (idle / talking / celebrating / encouraging),
3163
+ * and the a11y iron rule carried over — reduced-motion renders it static.
3164
+ * Upstream's Electron-native life (mic envelopes, typing squeeze, drag-throw)
3165
+ * has no web-panel equivalent and stays unported by design.
3166
+ * @module dsh-plugin-lookatstudy/client/companion
3167
+ */
3168
+ const COMPANION_FORMS = [
3169
+ {
3170
+ id: "ember",
3171
+ labelKey: "companion.form.ember",
3172
+ body: "#f59e0b",
3173
+ accent: "#dc2626"
3174
+ },
3175
+ {
3176
+ id: "frost",
3177
+ labelKey: "companion.form.frost",
3178
+ body: "#7dd3fc",
3179
+ accent: "#0284c7"
3180
+ },
3181
+ {
3182
+ id: "moss",
3183
+ labelKey: "companion.form.moss",
3184
+ body: "#86efac",
3185
+ accent: "#16a34a"
3186
+ },
3187
+ {
3188
+ id: "star",
3189
+ labelKey: "companion.form.star",
3190
+ body: "#c4b5fd",
3191
+ accent: "#7c3aed"
3192
+ },
3193
+ {
3194
+ id: "ink",
3195
+ labelKey: "companion.form.ink",
3196
+ body: "#cbd5e1",
3197
+ accent: "#334155"
3198
+ }
3199
+ ];
3200
+ /** Normalize a stored form id; junk falls back to 小焰 (upstream's default). */
3201
+ function normalizeCompanionForm(stored) {
3202
+ return COMPANION_FORMS.some((f) => f.id === stored) ? stored : "ember";
3203
+ }
3204
+ function storedCompanionForm() {
3205
+ try {
3206
+ return normalizeCompanionForm(localStorage.getItem("dsh-plugin-lookatstudy:companion-form"));
3207
+ } catch {
3208
+ return "ember";
3209
+ }
3210
+ }
3211
+ function storeCompanionForm(id) {
3212
+ try {
3213
+ localStorage.setItem("dsh-plugin-lookatstudy:companion-form", id);
3214
+ } catch {}
3215
+ }
3216
+ /**
3217
+ * The mood machine — pure. `celebrate` and `encourage` are momentary (the
3218
+ * view decays them back through `decay`); `talking` latches while read-aloud
3219
+ * runs; poke is a self-echoing celebration.
3220
+ */
3221
+ function nextCompanionMood(current, event) {
3222
+ switch (event) {
3223
+ case "talk-start": return "talking";
3224
+ case "talk-end": return current === "talking" ? "idle" : current;
3225
+ case "celebrate":
3226
+ case "poke": return "celebrating";
3227
+ case "encourage": return "encouraging";
3228
+ case "decay": return current === "celebrating" || current === "encouraging" ? "idle" : current;
3229
+ default: return current;
3230
+ }
3231
+ }
3232
+ /** One creature face — the shared skin every form wears. */
3233
+ function CompanionFace({ form, mood }) {
3234
+ const skin = COMPANION_FORMS.find((f) => f.id === form) ?? COMPANION_FORMS[0];
3235
+ const eye = mood === "celebrating" ? "M -3.4 0 q 1.7 -2.6 3.4 0" : "M -3.4 -1.4 a 1.4 1.4 0 1 0 0.01 0";
3236
+ const mouth = mood === "celebrating" ? "M 20 21 q 4 4 8 0" : mood === "encouraging" ? "M 21 22 q 3 -2.5 6 0" : "M 21 21 q 3 2.4 6 0";
3237
+ return (0, react.createElement)("svg", {
3238
+ width: 64,
3239
+ height: 64,
3240
+ viewBox: "0 0 64 64",
3241
+ "aria-hidden": "true"
3242
+ }, (0, react.createElement)("g", null, (0, react.createElement)("circle", {
3243
+ cx: 32,
3244
+ cy: 34,
3245
+ r: 20,
3246
+ fill: skin.body,
3247
+ stroke: skin.accent,
3248
+ strokeWidth: 2
3249
+ }), form === "ember" ? (0, react.createElement)("path", {
3250
+ d: "M32 4 q6 8 3 12 q8 -2 9 6 q4 -1 4 4 l-2 4 H18 l-2 -4 q0 -5 4 -4 q1 -8 9 -6 q-3 -4 3 -12z",
3251
+ fill: skin.accent,
3252
+ opacity: .85
3253
+ }) : form === "star" ? (0, react.createElement)("path", {
3254
+ d: "M32 2 l4 8 9 1 -6.5 6 1.5 9 -8.5 -4.5 -8 4.5 1.5 -9 -6.5 -6 9 -1z",
3255
+ fill: skin.accent,
3256
+ opacity: .85
3257
+ }) : form === "moss" ? (0, react.createElement)("path", {
3258
+ d: "M32 2 q-2 6 2 10 q-8 0 -8 6 H40 q0 -6 -8 -6 q4 -4 0 -10z",
3259
+ fill: skin.accent,
3260
+ opacity: .85
3261
+ }) : form === "ink" ? (0, react.createElement)("path", {
3262
+ d: "M32 2 q8 12 8 18 a8 8 0 1 1 -16 0 q0 -6 8 -18z",
3263
+ fill: skin.accent,
3264
+ opacity: .55
3265
+ }) : (0, react.createElement)("path", {
3266
+ d: "M18 12 l3 5 5 -1 -1 5 5 3 -5 3 1 5 -5 -1 -3 5 -3 -5 -5 1 1 -5 -5 -3 5 -3 -1 -5 5 1z",
3267
+ fill: skin.accent,
3268
+ opacity: .7
3269
+ }), (0, react.createElement)("path", {
3270
+ d: eye,
3271
+ transform: "translate(25 30)",
3272
+ stroke: skin.accent,
3273
+ strokeWidth: 1.6,
3274
+ fill: mood === "celebrating" ? "none" : skin.accent,
3275
+ strokeLinecap: "round"
3276
+ }), (0, react.createElement)("path", {
3277
+ d: eye,
3278
+ transform: "translate(14 30)",
3279
+ stroke: skin.accent,
3280
+ strokeWidth: 1.6,
3281
+ fill: mood === "celebrating" ? "none" : skin.accent,
3282
+ strokeLinecap: "round"
3283
+ }), (0, react.createElement)("path", {
3284
+ d: mouth,
3285
+ stroke: skin.accent,
3286
+ strokeWidth: 1.8,
3287
+ fill: "none",
3288
+ strokeLinecap: "round"
3289
+ })));
3290
+ }
3291
+ /** The creature: fixed bottom-right inside the panel, mood-animated, pokeable. */
3292
+ function Companion({ mood, onPoke }) {
3293
+ const form = storedCompanionForm();
3294
+ return (0, react.createElement)("div", {
3295
+ className: `lks-companion mood-${mood}`,
3296
+ "data-lks-companion": form,
3297
+ title: tr("companion.poke"),
3298
+ role: "button",
3299
+ tabIndex: 0,
3300
+ "aria-label": tr("companion.poke"),
3301
+ onClick: () => {
3302
+ onPoke?.();
3303
+ },
3304
+ onKeyDown: (e) => {
3305
+ if (e.key === "Enter" || e.key === " ") {
3306
+ e.preventDefault();
3307
+ onPoke?.();
3308
+ }
3309
+ }
3310
+ }, (0, react.createElement)(CompanionFace, {
3311
+ form,
3312
+ mood
3313
+ }));
3314
+ }
3315
+ /** The hook driving one creature instance: event → mood, momentary decay. */
3316
+ function useCompanionMood() {
3317
+ const [mood, setMood] = (0, react.useState)("idle");
3318
+ const moodRef = (0, react.useRef)(mood);
3319
+ moodRef.current = mood;
3320
+ const emit = (event) => {
3321
+ const next = nextCompanionMood(moodRef.current, event);
3322
+ if (next !== moodRef.current) {
3323
+ moodRef.current = next;
3324
+ setMood(next);
3325
+ }
3326
+ };
3327
+ (0, react.useEffect)(() => {
3328
+ if (mood !== "celebrating" && mood !== "encouraging") return;
3329
+ const timer = setTimeout(() => {
3330
+ moodRef.current = nextCompanionMood(moodRef.current, "decay");
3331
+ setMood(moodRef.current);
3332
+ }, 2400);
3333
+ return () => {
3334
+ clearTimeout(timer);
3335
+ };
3336
+ }, [mood]);
3337
+ return {
3338
+ mood,
3339
+ emit
3340
+ };
3341
+ }
3342
+ //#endregion
3343
+ //#region src/client/highlights.ts
3344
+ /**
3345
+ * Locate a selection inside the model text: exact `selected` text search,
3346
+ * disambiguated by a fuzzy `surrounding` fingerprint (the same sentence
3347
+ * appearing twice resolves to the occurrence whose context matches).
3348
+ * @returns the global [start, end) in model.text, or null when absent.
3349
+ */
3350
+ function locateInModel(model, selected, surrounding) {
3351
+ const needle = selected.trim();
3352
+ if (needle.length < 2) return null;
3353
+ const hits = [];
3354
+ let from = 0;
3355
+ for (;;) {
3356
+ const hit = model.text.indexOf(needle, from);
3357
+ if (hit === -1) break;
3358
+ hits.push(hit);
3359
+ from = hit + 1;
3360
+ }
3361
+ if (hits.length === 0) return null;
3362
+ if (hits.length === 1 || surrounding === void 0 || surrounding.trim() === "") return {
3363
+ start: hits[0],
3364
+ end: hits[0] + needle.length
3365
+ };
3366
+ const finger = surrounding.replace(/\s+/g, "");
3367
+ let best = hits[0];
3368
+ let bestScore = -1;
3369
+ for (const hit of hits) {
3370
+ const context = model.text.slice(Math.max(0, hit - 30), hit + needle.length + 30).replace(/\s+/g, "");
3371
+ let score = 0;
3372
+ for (let i = 0; i < finger.length; i += 4) if (context.includes(finger.slice(i, i + 4))) score += 1;
3373
+ if (score > bestScore) {
3374
+ bestScore = score;
3375
+ best = hit;
3376
+ }
3377
+ }
3378
+ return {
3379
+ start: best,
3380
+ end: best + needle.length
3381
+ };
3382
+ }
3383
+ /** Plan the per-node segments covering global [start, end) — pure. */
3384
+ function planSegments(nodes, start, end) {
3385
+ const segments = [];
3386
+ nodes.forEach((entry, index) => {
3387
+ if (entry.end <= start || entry.start >= end) return;
3388
+ segments.push({
3389
+ index,
3390
+ localStart: Math.max(0, start - entry.start),
3391
+ localEnd: Math.min(entry.end - entry.start, end - entry.start)
3392
+ });
3393
+ });
3394
+ return segments;
3395
+ }
3396
+ /** Build the DOM text model for the prose container (the walker rules mirror
3397
+ * upstream: skip script/style and KaTeX glyph layers; skip existing marks so
3398
+ * save-time and apply-time models stay symmetric). */
3399
+ function getTextModel(container, opts) {
3400
+ const nodes = [];
3401
+ let text = "";
3402
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode(node) {
3403
+ const parent = node.parentElement;
3404
+ if (parent === null) return NodeFilter.FILTER_REJECT;
3405
+ const tag = parent.tagName;
3406
+ if (tag === "SCRIPT" || tag === "STYLE") return NodeFilter.FILTER_REJECT;
3407
+ if (parent.closest(".katex-html")) return NodeFilter.FILTER_REJECT;
3408
+ if (parent.closest(".katex-mathml") !== null && parent.closest("annotation") === null) return NodeFilter.FILTER_REJECT;
3409
+ if (opts?.includeMarks !== true && parent.closest("mark.lks-hl") !== null) return NodeFilter.FILTER_REJECT;
3410
+ return NodeFilter.FILTER_ACCEPT;
3411
+ } });
3412
+ let n;
3413
+ while ((n = walker.nextNode()) !== null) {
3414
+ const t = n;
3415
+ const content = t.textContent ?? "";
3416
+ nodes.push({
3417
+ node: t,
3418
+ start: text.length,
3419
+ end: text.length + content.length
3420
+ });
3421
+ text += content;
3422
+ }
3423
+ return {
3424
+ text,
3425
+ nodes
3426
+ };
3427
+ }
3428
+ /** The mark class applied to persisted highlights. */
3429
+ const HIGHLIGHT_MARK = "lks-hl";
3430
+ /**
3431
+ * Apply one highlight range to the live DOM: split the boundary text nodes
3432
+ * and wrap the covering segments in marks. Returns the created marks.
3433
+ */
3434
+ function wrapRange(model, start, end) {
3435
+ const marks = [];
3436
+ for (const seg of planSegments(model.nodes, start, end)) {
3437
+ const node = model.nodes[seg.index].node;
3438
+ if (node.parentElement === null) continue;
3439
+ if (node.parentElement.closest(`mark.lks-hl`) !== null) continue;
3440
+ const mid = seg.localStart > 0 ? node.splitText(seg.localStart) : node;
3441
+ const localEnd = seg.localEnd - seg.localStart;
3442
+ if (mid.textContent !== null && mid.textContent.length > localEnd) mid.splitText(localEnd);
3443
+ const mark = document.createElement("mark");
3444
+ mark.className = HIGHLIGHT_MARK;
3445
+ mid.parentElement.insertBefore(mark, mid);
3446
+ mark.appendChild(mid);
3447
+ marks.push(mark);
3448
+ }
3449
+ return marks;
3450
+ }
3451
+ /**
3452
+ * Render the persisted highlights for one lesson: each record-zone note with a
3453
+ * quote anchors a mark. Idempotent per call — call on a freshly rendered
3454
+ * (pre-mark) container; the model skips existing marks so re-entry is safe.
3455
+ * @returns how many notes got at least one mark.
3456
+ */
3457
+ function applyHighlights(container, quotes) {
3458
+ let applied = 0;
3459
+ for (const quote of quotes) {
3460
+ const trimmed = quote.trim();
3461
+ if (trimmed.length < 2) continue;
3462
+ const model = getTextModel(container);
3463
+ const located = locateInModel(model, trimmed, void 0);
3464
+ if (located === null) continue;
3465
+ if (wrapRange(model, located.start, located.end).length > 0) applied += 1;
3466
+ }
3467
+ return applied;
3468
+ }
3469
+ //#endregion
2358
3470
  //#region src/client/views.tsx
2359
3471
  /**
2360
3472
  * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
@@ -2394,7 +3506,13 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2394
3506
  run = [];
2395
3507
  };
2396
3508
  for (const line of text.split("\n")) {
2397
- const match = /^([A-D])[.、:)]\s+(.+)$/.exec(line.trim());
3509
+ const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
3510
+ const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
3511
+ const match = plain ?? (table !== null ? [
3512
+ table[0],
3513
+ table[1],
3514
+ table[2].replaceAll("**", "").replaceAll("`", "")
3515
+ ] : null);
2398
3516
  if (match === null) {
2399
3517
  flush();
2400
3518
  continue;
@@ -2416,6 +3534,56 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2416
3534
  return runs.at(-1) ?? [];
2417
3535
  }
2418
3536
  /**
3537
+ * Merge the rail's two search tracks into the results panel: title hits
3538
+ * (lessons whose name matches — they are also filtered into the tree) first,
3539
+ * then full-text hits not already covered, snippets carried for the body
3540
+ * matches. Pure; empty queries yield no panel.
3541
+ */
3542
+ function mergeRailSearch(query, lessons, textHits) {
3543
+ const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
3544
+ if (keys.length === 0) return [];
3545
+ const rows = [];
3546
+ const seen = /* @__PURE__ */ new Set();
3547
+ const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
3548
+ for (const lesson of lessons) {
3549
+ if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
3550
+ rows.push({
3551
+ lessonId: lesson.id,
3552
+ title: lesson.title,
3553
+ snippet: "",
3554
+ courseTitle: "",
3555
+ fromTitle: true
3556
+ });
3557
+ seen.add(lesson.id);
3558
+ }
3559
+ for (const hit of textHits) {
3560
+ if (seen.has(hit.lessonId)) continue;
3561
+ rows.push({
3562
+ lessonId: hit.lessonId,
3563
+ title: hit.lessonTitle,
3564
+ snippet: hit.snippet,
3565
+ courseTitle: hit.courseTitle,
3566
+ fromTitle: false
3567
+ });
3568
+ seen.add(hit.lessonId);
3569
+ }
3570
+ return rows.slice(0, 12);
3571
+ }
3572
+ /**
3573
+ * A section's effective openness: the user's explicit toggle (persisted per
3574
+ * course) overrides the frontier-based default. Pure.
3575
+ */
3576
+ function effectiveOpen(sectionTitle, defaultOpen, overrides) {
3577
+ return overrides[sectionTitle] ?? defaultOpen;
3578
+ }
3579
+ /**
3580
+ * The narrow-mode pane picker: only valid ids, remembering the last choice
3581
+ * (upstream's narrow switcher semantics). Pure.
3582
+ */
3583
+ function pickNarrowPane(stored) {
3584
+ return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
3585
+ }
3586
+ /**
2419
3587
  * Default expansion for one rail section: collapsed when every study lesson is
2420
3588
  * done (mastered) or not yet reachable (locked). The focus lesson's section
2421
3589
  * stays open; exam nodes never force a section open. Pure.
@@ -2469,12 +3637,43 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2469
3637
  if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
2470
3638
  return (0, react.createElement)(IconLockFill16, { size: 14 });
2471
3639
  }
3640
+ /** Toast severity glyph (P6): the state color rides the icon, text stays ink. */
3641
+ function toastIcon(severity) {
3642
+ if (severity === "success") return (0, react.createElement)(IconStarFill16, {
3643
+ size: 14,
3644
+ className: "lks-toast-glyph ok"
3645
+ });
3646
+ if (severity === "warning") return (0, react.createElement)(IconWarningOutline16, {
3647
+ size: 14,
3648
+ className: "lks-toast-glyph warn"
3649
+ });
3650
+ if (severity === "error") return (0, react.createElement)(IconWarningOutline16, {
3651
+ size: 14,
3652
+ className: "lks-toast-glyph err"
3653
+ });
3654
+ if (severity === "info") return (0, react.createElement)(IconGlobeOutline14, {
3655
+ size: 13,
3656
+ className: "lks-toast-glyph info"
3657
+ });
3658
+ return null;
3659
+ }
2472
3660
  function examOpen(lessons) {
2473
3661
  return lessons.every((l) => l.kind !== "study" || (l.masteryPct ?? 0) >= 50);
2474
3662
  }
2475
3663
  function titleMatches(title, query) {
2476
3664
  return query.trim().toLowerCase().split(/\s+/).filter(Boolean).every((k) => title.toLowerCase().includes(k));
2477
3665
  }
3666
+ /**
3667
+ * Whether the host session list actually knows a bound thread id. A restart
3668
+ * can drop young sessions from persistence; an empty byId means the list has
3669
+ * not hydrated yet (assume known — the send's own open surfaces a real error).
3670
+ * Pure over the injected list snapshot.
3671
+ */
3672
+ function sessionKnown(ctx, id) {
3673
+ const byId = (ctx.sessions.list?.getSnapshot())?.byId;
3674
+ if (byId === void 0) return true;
3675
+ return Object.keys(byId).length === 0 ? true : id in byId;
3676
+ }
2478
3677
  /** One rendered chat row (shared shape with the read-aloud era's renderer). */
2479
3678
  function chatRow(row, interactive) {
2480
3679
  if (row.role === "user") return (0, react.createElement)("div", {
@@ -2520,26 +3719,95 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2520
3719
  const [sendError, setSendError] = (0, react.useState)(null);
2521
3720
  const [busy, setBusy] = (0, react.useState)(false);
2522
3721
  const [rows, setRows] = (0, react.useState)([]);
3722
+ const [feedAttached, setFeedAttached] = (0, react.useState)(false);
2523
3723
  const [draft, setDraft] = (0, react.useState)("");
3724
+ const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
2524
3725
  const lesson = data?.lesson ?? null;
2525
3726
  const localBound = (0, react.useRef)(null);
2526
3727
  const streamEl = (0, react.useRef)(null);
2527
3728
  const boundId = localBound.current ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
3729
+ const nudged = (0, react.useRef)(false);
3730
+ (0, react.useEffect)(() => {
3731
+ if (nudged.current || (data?.dueCount ?? 0) === 0) return;
3732
+ nudged.current = true;
3733
+ showStudyToast(tr("review.nudge", { n: data.dueCount }), {
3734
+ severity: "warning",
3735
+ action: {
3736
+ label: tr("review.nudge.go"),
3737
+ onClick: () => {
3738
+ const first = data?.due[0];
3739
+ if (first !== void 0) setFocus(first.lessonId).catch(() => {});
3740
+ }
3741
+ }
3742
+ });
3743
+ }, [
3744
+ data?.dueCount,
3745
+ data?.due,
3746
+ setFocus
3747
+ ]);
2528
3748
  (0, react.useEffect)(() => {
2529
3749
  if (boundId === null) {
2530
3750
  setRows([]);
3751
+ setFeedAttached(false);
2531
3752
  return;
2532
3753
  }
2533
- const source = ctx.sessions.binding(boundId)?.eventSource;
2534
- if (source === void 0) {
2535
- setRows([]);
2536
- return;
3754
+ setFeedAttached(false);
3755
+ let disposed = false;
3756
+ let offSource;
3757
+ let offList;
3758
+ const attach = () => {
3759
+ const source = ctx.sessions.binding(boundId)?.eventSource;
3760
+ if (source === void 0) return false;
3761
+ const update = () => {
3762
+ setRows(feedRows(source.getSnapshot()));
3763
+ };
3764
+ update();
3765
+ setFeedAttached(true);
3766
+ offSource = source.subscribe(update);
3767
+ return true;
3768
+ };
3769
+ const stage = () => {
3770
+ if (!sessionKnown(ctx, boundId)) return;
3771
+ try {
3772
+ const shell = PANEL_SHELL.current;
3773
+ if (shell !== null) shell.suppressHandBack(() => {
3774
+ ctx.sessions.open(boundId);
3775
+ });
3776
+ else ctx.sessions.open(boundId);
3777
+ } catch {}
3778
+ };
3779
+ const reconcile = () => {
3780
+ if (disposed || offSource !== void 0) return true;
3781
+ if (ctx.sessions.list?.getSnapshot().current !== boundId) {
3782
+ stage();
3783
+ return false;
3784
+ }
3785
+ return attach();
3786
+ };
3787
+ if (!reconcile()) {
3788
+ let tries = 0;
3789
+ const timer = setInterval(() => {
3790
+ if (disposed || offSource !== void 0 || reconcile() || ++tries >= 40) clearInterval(timer);
3791
+ }, 150);
3792
+ const list = ctx.sessions.list;
3793
+ if (list !== void 0) offList = list.subscribe(() => {
3794
+ if (disposed || reconcile()) {
3795
+ offList?.();
3796
+ offList = void 0;
3797
+ }
3798
+ });
3799
+ return () => {
3800
+ disposed = true;
3801
+ clearInterval(timer);
3802
+ offSource?.();
3803
+ offList?.();
3804
+ };
2537
3805
  }
2538
- const update = () => {
2539
- setRows(feedRows(source.getSnapshot()));
3806
+ return () => {
3807
+ disposed = true;
3808
+ offSource?.();
3809
+ offList?.();
2540
3810
  };
2541
- update();
2542
- return source.subscribe(update);
2543
3811
  }, [boundId, ctx]);
2544
3812
  (0, react.useEffect)(() => {
2545
3813
  const el = streamEl.current;
@@ -2553,8 +3821,9 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2553
3821
  setSendError(null);
2554
3822
  try {
2555
3823
  if (data?.active !== true) await activate(true);
2556
- let sessionId = localBound.current ?? data?.lessonSessions[lesson.lessonId];
2557
- if (sessionId === void 0 || sessionId === null) {
3824
+ let sessionId = localBound.current ?? data?.lessonSessions[lesson.lessonId] ?? null;
3825
+ if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
3826
+ if (sessionId === null) {
2558
3827
  const area = await fetch("/lookatstudy/api/study-workspace");
2559
3828
  if (!area.ok) throw new Error(`study area unavailable (HTTP ${String(area.status)})`);
2560
3829
  const { path } = await area.json();
@@ -2584,7 +3853,12 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2584
3853
  }
2585
3854
  })();
2586
3855
  };
2587
- const body = (0, react.createElement)("div", { className: "lks14-body" }, (0, react.createElement)(CourseRail, {
3856
+ const companion = useCompanionMood();
3857
+ const setCompanionEvent = companion.emit;
3858
+ const body = (0, react.createElement)("div", {
3859
+ className: "lks14-body",
3860
+ "data-pane": narrowPane
3861
+ }, (0, react.createElement)(CourseRail, {
2588
3862
  data,
2589
3863
  activate,
2590
3864
  setFocus,
@@ -2595,20 +3869,85 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2595
3869
  data,
2596
3870
  lesson,
2597
3871
  rows,
3872
+ feedAttached,
3873
+ bound: boundId !== null,
2598
3874
  busy,
2599
3875
  sendError,
2600
3876
  draft,
2601
3877
  setDraft,
2602
3878
  send,
2603
- setMode
3879
+ setMode,
3880
+ narrowPane,
3881
+ companionEvent: setCompanionEvent
2604
3882
  }), (0, react.createElement)(NotebookPane, {
2605
3883
  data,
2606
- deleteNote
3884
+ deleteNote,
3885
+ send,
3886
+ companionEvent: setCompanionEvent
2607
3887
  }));
2608
3888
  return (0, react.createElement)("div", {
2609
3889
  className: "lks14",
2610
3890
  "data-lks-panel": ""
2611
- }, body);
3891
+ }, (0, react.createElement)(Companion, {
3892
+ mood: companion.mood,
3893
+ onPoke: () => {
3894
+ setCompanionEvent("poke");
3895
+ showStudyToast(tr("companion.poked"), { severity: "success" });
3896
+ }
3897
+ }), (0, react.createElement)("div", { className: "lks14-switch" }, ...[
3898
+ "rail",
3899
+ "chat",
3900
+ "note"
3901
+ ].map((pane) => (0, react.createElement)("button", {
3902
+ key: pane,
3903
+ className: `lks14-switch-btn${narrowPane === pane ? " on" : ""}`,
3904
+ "aria-pressed": String(narrowPane === pane),
3905
+ onClick: () => {
3906
+ setNarrowPane(pane);
3907
+ }
3908
+ }, tr(`pane.${pane}`)))), body, (0, react.createElement)(StudyToastStack));
3909
+ }
3910
+ /** P6: the panel's toast stack (upstream Toast port) — severity capsules,
3911
+ * top-center, auto-dismiss with an exit-animation handshake; the store clears
3912
+ * on unmount so no toast outlives the panel. */
3913
+ function StudyToastStack() {
3914
+ const [items, setItems] = (0, react.useState)([]);
3915
+ (0, react.useEffect)(() => {
3916
+ const update = () => {
3917
+ setItems([...toastStore.getSnapshot()]);
3918
+ };
3919
+ update();
3920
+ const unsubscribe = toastStore.subscribe(update);
3921
+ return () => {
3922
+ unsubscribe();
3923
+ toastStore.clear();
3924
+ };
3925
+ }, []);
3926
+ return (0, react.createElement)("div", {
3927
+ className: "lks-toasts",
3928
+ role: "region",
3929
+ "aria-live": "polite",
3930
+ "aria-label": tr("toast.region")
3931
+ }, ...items.map((t) => (0, react.createElement)("div", {
3932
+ key: t.id,
3933
+ className: `lks-toast${t.exiting ? " exiting" : ""}`,
3934
+ "data-severity": t.severity,
3935
+ onAnimationEnd: t.exiting ? () => {
3936
+ toastStore.finish(t.id);
3937
+ } : void 0
3938
+ }, toastIcon(t.severity), (0, react.createElement)("span", { className: "lks-toast-text" }, t.message), t.action === void 0 ? null : (0, react.createElement)("button", {
3939
+ className: "lks-toast-action",
3940
+ onClick: () => {
3941
+ t.action?.onClick?.();
3942
+ toastStore.startExit(t.id);
3943
+ }
3944
+ }, t.action.label), (0, react.createElement)("button", {
3945
+ className: "lks-toast-close",
3946
+ "aria-label": tr("toast.close"),
3947
+ onClick: () => {
3948
+ toastStore.startExit(t.id);
3949
+ }
3950
+ }, "×"))));
2612
3951
  }
2613
3952
  /** Module-level shell handle so the panel can suppress hand-back on internal opens. */
2614
3953
  const PANEL_SHELL = { current: null };
@@ -2619,6 +3958,14 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2619
3958
  function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send }) {
2620
3959
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
2621
3960
  const [query, setQuery] = (0, react.useState)("");
3961
+ const [searchRows, setSearchRows] = (0, react.useState)([]);
3962
+ const [sectionOverrides, setSectionOverrides] = (0, react.useState)({});
3963
+ const toggleSection = (title, next) => {
3964
+ setSectionOverrides((cur) => ({
3965
+ ...cur,
3966
+ [title]: next
3967
+ }));
3968
+ };
2622
3969
  const [confirmDelete, setConfirmDelete] = (0, react.useState)(false);
2623
3970
  const [showImport, setShowImport] = (0, react.useState)(false);
2624
3971
  const [error, setError] = (0, react.useState)(null);
@@ -2640,6 +3987,36 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2640
3987
  window.removeEventListener("keydown", onKey);
2641
3988
  };
2642
3989
  }, [confirmDelete]);
3990
+ (0, react.useEffect)(() => {
3991
+ if (query.trim().length < 2 || data === null) {
3992
+ setSearchRows([]);
3993
+ return;
3994
+ }
3995
+ let cancelled = false;
3996
+ const timer = setTimeout(() => {
3997
+ searchLessons(query).then((textHits) => {
3998
+ if (cancelled) return;
3999
+ const lessons = (data.courses.find((c) => c.courseId === (data.courses.some((x) => x.courseId === selectedCourse) ? selectedCourse : data.courses[0]?.courseId))?.sections ?? []).flatMap((s) => s.lessons);
4000
+ setSearchRows(mergeRailSearch(query, lessons.map((l) => ({
4001
+ id: l.id,
4002
+ title: l.title,
4003
+ status: l.status,
4004
+ kind: l.kind
4005
+ })), textHits));
4006
+ }).catch(() => {
4007
+ if (!cancelled) setSearchRows([]);
4008
+ });
4009
+ }, 250);
4010
+ return () => {
4011
+ cancelled = true;
4012
+ clearTimeout(timer);
4013
+ };
4014
+ }, [
4015
+ query,
4016
+ selectedCourse,
4017
+ data,
4018
+ searchLessons
4019
+ ]);
2643
4020
  const body = data === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("loading")) : data.courses.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("rail.empty.title"), (0, react.createElement)("br"), (0, react.createElement)("button", {
2644
4021
  className: "lks-btn primary",
2645
4022
  style: { margin: "10px 0" },
@@ -2691,25 +4068,34 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2691
4068
  setQuery(e.target.value);
2692
4069
  },
2693
4070
  onKeyDown: (e) => {
4071
+ if (e.key === "Escape") {
4072
+ setQuery("");
4073
+ setSearchRows([]);
4074
+ }
2694
4075
  if (e.key !== "Enter" || query.trim() === "") return;
2695
- for (const section of course.sections) {
2696
- const hit = section.lessons.find((l) => l.kind !== "exam" && l.status !== "locked" && titleMatches(l.title, query));
2697
- if (hit !== void 0) {
2698
- setFocus(hit.id).catch(reportError);
2699
- return;
2700
- }
4076
+ const first = searchRows[0];
4077
+ if (first !== void 0) {
4078
+ setQuery("");
4079
+ setSearchRows([]);
4080
+ setFocus(first.lessonId).catch(reportError);
2701
4081
  }
2702
- searchLessons(query).then((hits) => {
2703
- const hit = hits.find((h) => h.lessonId.startsWith(`${courseId}:`)) ?? hits[0];
2704
- if (hit !== void 0) {
2705
- setQuery("");
2706
- setFocus(hit.lessonId).catch(reportError);
2707
- }
2708
- }).catch(reportError);
2709
4082
  }
2710
- }), data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks14-duebox" }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("rail.due", { count: data.dueCount }), ...data.due.map((d) => (0, react.createElement)("div", {
4083
+ }), searchRows.length > 0 ? (0, react.createElement)("div", { className: "lks14-searchpanel" }, ...searchRows.map((row) => (0, react.createElement)("button", {
4084
+ key: row.lessonId,
4085
+ className: "lks14-searchrow",
4086
+ title: tr("rail.search.jump"),
4087
+ onClick: () => {
4088
+ setQuery("");
4089
+ setSearchRows([]);
4090
+ setFocus(row.lessonId).catch(reportError);
4091
+ }
4092
+ }, (0, react.createElement)("span", { className: "lks14-searchrow-title" }, row.title), row.courseTitle !== "" ? (0, react.createElement)("span", { className: "lks14-searchrow-course" }, row.courseTitle) : null, row.snippet !== "" ? (0, react.createElement)("span", { className: "lks14-searchrow-snip" }, row.snippet) : null))) : null, data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks14-duebox" }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("rail.due", { count: data.dueCount }), ...data.due.map((d) => (0, react.createElement)("button", {
2711
4093
  key: d.lessonId,
2712
- className: "lks14-dueitem"
4094
+ className: "lks14-dueitem",
4095
+ title: tr("review.jump"),
4096
+ onClick: () => {
4097
+ setFocus(d.lessonId).catch(reportError);
4098
+ }
2713
4099
  }, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks14-over" }, tr("rail.due.over", { days: d.overdueDays })) : null)), (0, react.createElement)("button", {
2714
4100
  className: "lks-btn ghost",
2715
4101
  style: { marginTop: "6px" },
@@ -2720,14 +4106,16 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2720
4106
  const examAllowed = examOpen(section.lessons);
2721
4107
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
2722
4108
  if (lessons.length === 0) return [];
2723
- const open = query.trim() !== "" || sectionDefaultOpen(section);
4109
+ const open = query.trim() !== "" || effectiveOpen(section.title, sectionDefaultOpen(section), sectionOverrides);
2724
4110
  return [(0, react.createElement)("button", {
2725
4111
  key: section.title,
2726
4112
  type: "button",
2727
4113
  className: "lks14-sechead",
2728
4114
  "aria-expanded": String(open),
2729
4115
  title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
2730
- onClick: () => {}
4116
+ onClick: () => {
4117
+ toggleSection(section.title, !open);
4118
+ }
2731
4119
  }, (0, react.createElement)("span", { className: "lks14-secnum" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks14-secheadt" }, section.title), (0, react.createElement)("span", { className: "lks14-secheadc" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
2732
4120
  const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
2733
4121
  return (0, react.createElement)("button", {
@@ -2780,7 +4168,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2780
4168
  }, tr("rail.empty.button"))), (0, react.createElement)("div", { className: "lks14-hint" }, tr("rail.empty.hint"), (0, react.createElement)("br"), tr("rail.empty.hint2")));
2781
4169
  }
2782
4170
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
2783
- function ChatPane({ data, lesson, rows, busy, sendError, draft, setDraft, send, setMode }) {
4171
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, setMode, narrowPane, companionEvent }) {
2784
4172
  const streamEl = (0, react.useRef)(null);
2785
4173
  (0, react.useEffect)(() => {
2786
4174
  const el = streamEl.current;
@@ -2803,7 +4191,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2803
4191
  if (draft.trim() !== "" && !busy) send(draft);
2804
4192
  }
2805
4193
  };
2806
- return (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, (0, react.createElement)("div", { className: "lks14-colhead" }, tr("col.tutor"), lesson !== null ? (0, react.createElement)("span", { className: "lks14-chatlesson" }, lesson.title) : null, (0, react.createElement)("span", { className: "lks14-pills" }, ...MODES$1.map((mode) => (0, react.createElement)("button", {
4194
+ const proposal = data?.pendingProposals[0] ?? null;
4195
+ return (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, proposal !== null ? (0, react.createElement)("div", { className: "lks-propbanner" }, (0, react.createElement)(IconCrownFill16, { size: 14 }), (0, react.createElement)("span", { className: "lks-propbanner-why" }, tr("proposal.banner", { lesson: proposal.lessonTitle }), " — ", proposal.rationale), (0, react.createElement)("button", {
4196
+ className: "lks-btn primary",
4197
+ style: {
4198
+ padding: "4px 12px",
4199
+ fontSize: "12.5px"
4200
+ },
4201
+ onClick: () => {
4202
+ send(tr("proposal.accept.msg", { lesson: proposal.lessonTitle }));
4203
+ }
4204
+ }, tr("proposal.accept")), (0, react.createElement)("button", {
4205
+ className: "lks-btn ghost",
4206
+ style: {
4207
+ padding: "4px 12px",
4208
+ fontSize: "12.5px"
4209
+ },
4210
+ onClick: () => {
4211
+ send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
4212
+ }
4213
+ }, tr("proposal.decline"))) : null, (0, react.createElement)("div", { className: "lks14-colhead" }, tr("col.tutor"), lesson !== null ? (0, react.createElement)("span", { className: "lks14-chatlesson" }, lesson.title) : null, (0, react.createElement)("span", { className: "lks14-pills" }, ...MODES$1.map((mode) => (0, react.createElement)("button", {
2807
4214
  key: mode.id,
2808
4215
  className: `lks14-pill${data?.mode === mode.id ? " on" : ""}`,
2809
4216
  "aria-pressed": String(data?.mode === mode.id),
@@ -2814,8 +4221,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2814
4221
  }
2815
4222
  }, tr(mode.labelKey))))), (0, react.createElement)("div", {
2816
4223
  className: "lks14-stream",
2817
- ref: streamEl
2818
- }, rows.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, dormant ? tr("tutor.dormant") : tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRow(row, !dormant && i === lastAssistant ? { send } : void 0))), (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
4224
+ ref: streamEl,
4225
+ "data-lks-feed": rows.length > 0 ? `rows:${String(rows.length)}` : bound ? feedAttached ? "attached-empty" : "waiting" : "no-thread"
4226
+ }, rows.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, dormant ? tr("tutor.dormant") : tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRow(row, !dormant && i === lastAssistant ? { send } : void 0)), rows.length > 0 && rows[rows.length - 1].role === "user" ? (0, react.createElement)("div", { className: "lks14-thinking" }, (0, react.createElement)(IconLoadingOutline16, {
4227
+ size: 13,
4228
+ className: "lks-spin"
4229
+ }), tr("tutor.thinking")) : null), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType === "quiz").map((a) => (0, react.createElement)(QuizCard, {
4230
+ key: a.id,
4231
+ lessonId: lesson.lessonId,
4232
+ artifactId: a.id,
4233
+ data: a.data,
4234
+ masteryPct: lesson.masteryPct,
4235
+ send,
4236
+ onFinished: (allCorrect) => {
4237
+ companionEvent(allCorrect ? "celebrate" : "encourage");
4238
+ }
4239
+ })), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(ArtifactCard, {
4240
+ key: a.id,
4241
+ artifact: a,
4242
+ send
4243
+ })), (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
2819
4244
  key: s.label,
2820
4245
  className: "lks14-starter",
2821
4246
  "aria-disabled": dormant || void 0,
@@ -2845,7 +4270,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2845
4270
  }) : null, tr("composer.send"))), sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null);
2846
4271
  }
2847
4272
  /** 右栏:the notebook — 讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
2848
- function NotebookPane({ data, deleteNote }) {
4273
+ function NotebookPane({ data, deleteNote, send, companionEvent }) {
2849
4274
  const lesson = data?.lesson ?? null;
2850
4275
  const [tab, setTab] = (0, react.useState)("teach");
2851
4276
  const [error, setError] = (0, react.useState)(null);
@@ -2853,7 +4278,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2853
4278
  const [read, setRead] = (0, react.useState)(null);
2854
4279
  const readCtl = (0, react.useRef)(null);
2855
4280
  const [readError, setReadError] = (0, react.useState)(null);
2856
- const { tts } = useStudy();
4281
+ const { tts, addUserNote, recordReview } = useStudy();
2857
4282
  const proseRef = (0, react.useRef)(null);
2858
4283
  const diagRef = (0, react.useRef)(null);
2859
4284
  (0, react.useEffect)(() => {
@@ -2878,8 +4303,94 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2878
4303
  setError(err instanceof Error ? err.message : String(err));
2879
4304
  });
2880
4305
  };
4306
+ const artifacts = lesson?.artifacts ?? [];
4307
+ const [, setUnseenTick] = (0, react.useState)(0);
4308
+ const unseen = unseenArtifacts(lesson?.lessonId ?? "", artifacts);
4309
+ const lastAnnounced = (0, react.useRef)(/* @__PURE__ */ new Set());
4310
+ (0, react.useEffect)(() => {
4311
+ const fresh = unseen.filter((id) => !lastAnnounced.current.has(id));
4312
+ if (fresh.length === 0) return;
4313
+ lastAnnounced.current = /* @__PURE__ */ new Set([...lastAnnounced.current, ...fresh]);
4314
+ showStudyToast(tr("artifact.sedimented"), { severity: "info" });
4315
+ setUnseenTick(Date.now());
4316
+ }, [unseen.join(","), lesson?.lessonId]);
4317
+ const heavy = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
4318
+ const latestHeavy = heavy.length > 0 ? heavy[heavy.length - 1] : null;
4319
+ const [quoteBtn, setQuoteBtn] = (0, react.useState)(null);
4320
+ const recordQuotes = (lesson?.notes ?? []).filter((n) => n.zone === "record" && n.quote !== null && n.quote.trim().length >= 2).map((n) => n.quote);
4321
+ const evaluateSelection = () => {
4322
+ const prose = proseRef.current;
4323
+ const selection = window.getSelection();
4324
+ if (prose === null || selection === null || selection.rangeCount === 0) return;
4325
+ const text = selection.toString().trim();
4326
+ if (text.length < 2 || text.length > 600) return;
4327
+ const range = selection.getRangeAt(0);
4328
+ if (prose.contains(range.commonAncestorContainer) !== true) return;
4329
+ const model = getTextModel(prose);
4330
+ const rect = range.getBoundingClientRect();
4331
+ const box = prose.getBoundingClientRect();
4332
+ const modelText = model.text;
4333
+ const idx = locateInModel(model, text, void 0)?.start ?? modelText.indexOf(text);
4334
+ const surrounding = idx >= 0 ? modelText.slice(Math.max(0, idx - 30), idx + text.length + 30) : text;
4335
+ setQuoteBtn({
4336
+ x: Math.min(Math.max(rect.left - box.left + rect.width / 2, 90), Math.max(box.width - 90, 90)),
4337
+ y: rect.top - box.top - 38,
4338
+ text,
4339
+ surrounding
4340
+ });
4341
+ };
4342
+ (0, react.useEffect)(() => {
4343
+ const SETTLE = 250;
4344
+ let settleTimer = null;
4345
+ let hideTimer = null;
4346
+ let gesture = false;
4347
+ const selectionHasText = () => (window.getSelection()?.toString().trim().length ?? 0) >= 2;
4348
+ const onChange = () => {
4349
+ if (selectionHasText()) {
4350
+ if (hideTimer !== null) {
4351
+ clearTimeout(hideTimer);
4352
+ hideTimer = null;
4353
+ }
4354
+ setQuoteBtn((cur) => cur === null ? cur : null);
4355
+ if (settleTimer !== null) clearTimeout(settleTimer);
4356
+ settleTimer = setTimeout(() => {
4357
+ settleTimer = null;
4358
+ if (!gesture && selectionHasText()) evaluateSelection();
4359
+ }, SETTLE);
4360
+ } else if (hideTimer === null) hideTimer = setTimeout(() => {
4361
+ setQuoteBtn((cur) => cur === null ? cur : null);
4362
+ }, 250);
4363
+ };
4364
+ const onDown = (e) => {
4365
+ if (e.target?.closest?.("[data-lks-quote-btn]") !== null) return;
4366
+ gesture = true;
4367
+ };
4368
+ const onUp = () => {
4369
+ gesture = false;
4370
+ if (settleTimer !== null) {
4371
+ clearTimeout(settleTimer);
4372
+ settleTimer = null;
4373
+ }
4374
+ if (hideTimer === null && selectionHasText()) evaluateSelection();
4375
+ };
4376
+ document.addEventListener("selectionchange", onChange);
4377
+ window.addEventListener("pointerdown", onDown, true);
4378
+ window.addEventListener("pointerup", onUp, true);
4379
+ window.addEventListener("pointercancel", onUp, true);
4380
+ return () => {
4381
+ document.removeEventListener("selectionchange", onChange);
4382
+ window.removeEventListener("pointerdown", onDown, true);
4383
+ window.removeEventListener("pointerup", onUp, true);
4384
+ window.removeEventListener("pointercancel", onUp, true);
4385
+ if (settleTimer !== null) clearTimeout(settleTimer);
4386
+ if (hideTimer !== null) clearTimeout(hideTimer);
4387
+ };
4388
+ }, []);
4389
+ const [enhanceTick, setEnhanceTick] = (0, react.useState)(0);
4390
+ const [rateBusy, setRateBusy] = (0, react.useState)(false);
2881
4391
  const startReading = () => {
2882
4392
  if (lesson === null || lesson.speechText.trim() === "") return;
4393
+ companionEvent("talk-start");
2883
4394
  readCtl.current?.stop();
2884
4395
  const sentences = speechSentencesOf(lesson.speechText);
2885
4396
  const voice = storedTtsVoice();
@@ -2960,6 +4471,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2960
4471
  });
2961
4472
  };
2962
4473
  const stopReading = () => {
4474
+ companionEvent("talk-end");
2963
4475
  readCtl.current?.stop();
2964
4476
  setRead(null);
2965
4477
  };
@@ -2980,7 +4492,10 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2980
4492
  ]);
2981
4493
  (0, react.useEffect)(() => {
2982
4494
  if (tab !== "teach" || proseRef.current === null || lesson === null) return;
2983
- enhanceRendered(proseRef.current).catch(() => {});
4495
+ enhanceRendered(proseRef.current).catch(() => {}).finally(() => {
4496
+ applyHighlights(proseRef.current, recordQuotes);
4497
+ setEnhanceTick(Date.now());
4498
+ });
2984
4499
  }, [tab, lesson?.html]);
2985
4500
  const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: "lks14-notebody" }, (0, react.createElement)("div", { className: "lks14-lessonhead" }, (0, react.createElement)("h2", null, lesson.title), (0, react.createElement)("div", { className: "lks14-meta" }, `${lesson.courseTitle} · ${statusTitle("study", lesson.status)}` + (lesson.masteryPct === null ? "" : ` · ${tr("bb.mastery", { pct: lesson.masteryPct })}`))), (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
2986
4501
  className: `lks14-viewtab${tab === "teach" ? " on" : ""}`,
@@ -3000,8 +4515,34 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3000
4515
  "aria-pressed": String(tab === "notes"),
3001
4516
  onClick: () => {
3002
4517
  setTab("notes");
4518
+ if (lesson !== null && unseen.length > 0) {
4519
+ markArtifactsSeen(lesson.lessonId, unseen);
4520
+ setUnseenTick(Date.now());
4521
+ }
4522
+ }
4523
+ }, tr("bb.notes"), unseen.length > 0 ? (0, react.createElement)("span", { className: "lks-viewtab-badge" }, String(unseen.length)) : null)), tab === "teach" ? (0, react.createElement)("div", null, lesson.due ? (0, react.createElement)("div", {
4524
+ className: "lks-ratecard",
4525
+ "data-lks-rate": lesson.lessonId
4526
+ }, (0, react.createElement)("div", { className: "lks-ratecard-title" }, tr("review.rate.title")), (0, react.createElement)("div", { className: "lks-ratecard-opts" }, ...[
4527
+ ["1", "review.again"],
4528
+ ["4", "review.remembered"],
4529
+ ["5", "review.mastered"]
4530
+ ].map(([q, key]) => (0, react.createElement)("button", {
4531
+ key: q,
4532
+ className: `lks-ratecard-opt${q === "1" ? " again" : q === "5" ? " best" : ""}`,
4533
+ disabled: rateBusy,
4534
+ onClick: () => {
4535
+ setRateBusy(true);
4536
+ recordReview(lesson.lessonId, Number(q)).then(() => {
4537
+ companionEvent(Number(q) >= 4 ? "celebrate" : "encourage");
4538
+ showStudyToast(Number(q) >= 4 ? tr("review.done.good", { days: 1 }) : tr("review.done.again"), { severity: Number(q) >= 4 ? "success" : "warning" });
4539
+ setRateBusy(false);
4540
+ }).catch((err) => {
4541
+ setError(err instanceof Error ? err.message : String(err));
4542
+ setRateBusy(false);
4543
+ });
3003
4544
  }
3004
- }, tr("bb.notes"))), tab === "teach" ? (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
4545
+ }, tr(key))))) : null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
3005
4546
  className: "lks-btn ghost",
3006
4547
  style: {
3007
4548
  padding: "3px 8px",
@@ -3029,14 +4570,49 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3029
4570
  },
3030
4571
  title: tr("read.stop"),
3031
4572
  onClick: stopReading
3032
- }, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null, read !== null && read.degraded ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, tr("read.engine.system")) : null), (0, react.createElement)("div", {
4573
+ }, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null, read !== null && read.degraded ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, tr("read.engine.system")) : null), (0, react.createElement)("div", { className: "lks14-prosewrap" }, (0, react.createElement)("div", {
3033
4574
  className: "lks14-prose",
3034
4575
  ref: proseRef,
3035
4576
  dangerouslySetInnerHTML: { __html: lesson.html }
3036
- })) : tab === "cmap" ? (0, react.createElement)("div", {
4577
+ }), quoteBtn !== null ? (0, react.createElement)("div", {
4578
+ className: "lks-quote-btn",
4579
+ "data-lks-quote-btn": "",
4580
+ style: {
4581
+ left: `${quoteBtn.x}px`,
4582
+ top: `${quoteBtn.y}px`
4583
+ }
4584
+ }, (0, react.createElement)("button", { onClick: () => {
4585
+ send(tr("note.quote.template", { text: quoteBtn.text.length > 200 ? `${quoteBtn.text.slice(0, 200)}…` : quoteBtn.text }));
4586
+ setQuoteBtn(null);
4587
+ window.getSelection()?.removeAllRanges();
4588
+ } }, tr("note.quote.ask")), (0, react.createElement)("button", { onClick: () => {
4589
+ if (lesson !== null) {
4590
+ addUserNote(lesson.lessonId, quoteBtn.text).catch((err) => {
4591
+ setError(err instanceof Error ? err.message : String(err));
4592
+ });
4593
+ applyHighlights(proseRef.current, [quoteBtn.text]);
4594
+ showStudyToast(tr("note.saved"), { severity: "success" });
4595
+ }
4596
+ setQuoteBtn(null);
4597
+ window.getSelection()?.removeAllRanges();
4598
+ } }, tr("note.quote.save"))) : null), latestHeavy !== null ? (0, react.createElement)("div", { className: "lks-acard-stage" }, (0, react.createElement)(ArtifactCard, {
4599
+ artifact: latestHeavy,
4600
+ send: () => {}
4601
+ })) : null) : tab === "cmap" ? (0, react.createElement)("div", {
3037
4602
  className: "lks14-prose",
3038
4603
  ref: diagRef
3039
- }) : (0, react.createElement)("div", { className: "lks14-zones" }, lesson.notes.length === 0 ? (0, react.createElement)("div", {
4604
+ }) : (0, react.createElement)("div", { className: "lks14-zones" }, (() => {
4605
+ const understand = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
4606
+ if (understand.length === 0) return null;
4607
+ return (0, react.createElement)("div", {
4608
+ key: "artifacts",
4609
+ className: "lks14-zone"
4610
+ }, (0, react.createElement)("div", { className: "lks14-zoneh" }, tr("zone.artifacts")), ...understand.map((a) => (0, react.createElement)(ArtifactCard, {
4611
+ key: a.id,
4612
+ artifact: a,
4613
+ send: () => {}
4614
+ })));
4615
+ })(), lesson.notes.length === 0 ? (0, react.createElement)("div", {
3040
4616
  className: "lks14-empty",
3041
4617
  style: { padding: "16px 0" }
3042
4618
  }, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
@@ -3317,6 +4893,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3317
4893
  const { data, setMode, activate } = useStudy();
3318
4894
  const [error, setError] = (0, react.useState)(null);
3319
4895
  const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
4896
+ const [companion, setCompanion] = (0, react.useState)(storedCompanionForm);
3320
4897
  const fire = (action) => {
3321
4898
  action.then(() => {
3322
4899
  setError(null);
@@ -3337,7 +4914,17 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3337
4914
  onClick: () => {
3338
4915
  if (data !== null) fire(activate(!data.active));
3339
4916
  }
3340
- }, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
4917
+ }, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.companion")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.companion.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
4918
+ className: "lks-set-select",
4919
+ value: companion,
4920
+ onChange: (e) => {
4921
+ storeCompanionForm(normalizeCompanionForm(e.target.value));
4922
+ setCompanion(normalizeCompanionForm(e.target.value));
4923
+ }
4924
+ }, ...COMPANION_FORMS.map((f) => (0, react.createElement)("option", {
4925
+ key: f.id,
4926
+ value: f.id
4927
+ }, tr(f.labelKey)))))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
3341
4928
  className: "lks-set-select",
3342
4929
  value: voice,
3343
4930
  onChange: (e) => {