dsh-plugin-lookatstudy 0.14.1 → 0.16.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,10 +4,313 @@ 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");
21
+ //#region src/client/upstream-theme.ts
22
+ /**
23
+ * The upstream LookatStudy skin (0.16.0 P9a) — the 1:1 visual port. Upstream
24
+ * is a Duolingo-style dark-first design system defined in its tailwind.config
25
+ * + index.css :root; this module carries those EXACT tokens (scoped to the
26
+ * panel root `.lks-ui`) and redefines the panel's class vocabulary to match
27
+ * upstream's components: the three-column surface ladder (rail darkest / chat
28
+ * surface-1 / notebook surface-2, no borders — depth by color step), the
29
+ * floating blur app-header, 3D push-down buttons, the claude.ai-style
30
+ * composer capsule, chat bubbles, typing dots, and the toast capsules.
31
+ * Host-chrome surfaces (dock pill, settings, toolviews) keep the dsw tokens —
32
+ * only the study panel wears the upstream skin.
33
+ * @module dsh-plugin-lookatstudy/client/upstream-theme
34
+ */
35
+ const UPSTREAM_CSS = `
36
+ /* ── upstream design tokens (index.css :root, verbatim values) ── */
37
+ .lks-ui{
38
+ --brand:#58CC01;--brand-rgb:88 204 2;--brand-dark:#46A302;--brand-light:#7ED957;
39
+ --brand-glow:#58CC0159;--brand-ring:#58CC0126;
40
+ --accent:#1DB0F6;--accent-rgb:28 176 246;--accent-dark:#098CDC;--accent-light:#65C6FF;
41
+ --accent-glow:#1DB0F64C;--accent-ring:#1DB0F626;
42
+ --gold:#FFC801;--gold-rgb:255 200 0;--gold-dark:#D59800;--gold-light:#FFE294;
43
+ --gold-glow:#FFC80173;--gold-ring:#FFC80133;
44
+ --warning:#FF4B4B;--warning-rgb:255 75 75;--warning-dark:#D01C29;--warning-light:#FF7E77;
45
+ --warning-glow:#FF4B4B4C;--warning-ring:#FF4B4B26;--warning-tint:#FF4B4B1A;--warning-tint-border:#FF4B4B40;
46
+ --review:#FA6E1D;--review-rgb:255 122 26;--review-glow:#FA6E1D26;--review-tint:#FA6E1D1A;
47
+ --exam:#A855F7;--exam-rgb:168 85 247;--exam-dark:#7E22CE;--exam-light:#C084FC;--exam-locked:#524569;
48
+ --exam-glow:#A855F759;--exam-ring:#A855F726;
49
+ --surface-rail:#08090B;--surface-rail-rgb:8 9 11;
50
+ --surface-0:#0C0D0F;--surface-0-rgb:12 13 15;
51
+ --surface-1:#111114;--surface-1-rgb:17 17 20;
52
+ --surface-2:#1A1A1D;--surface-2-rgb:26 26 29;
53
+ --surface-3:#2A2B2E;--surface-3-rgb:42 43 46;
54
+ --ink:#F1F2F4;--ink-rgb:245 245 250;
55
+ --ink-strong:#FAFAFA;--ink-strong-rgb:250 250 250;
56
+ --ink-muted:#9D9EA2;--ink-muted-rgb:166 166 176;
57
+ --ink-faint:#88898C;--ink-faint-rgb:136 137 140;
58
+ --border:#252629;--border-rgb:37 38 41;--border-faint:#1A1A1D;--border-faint-rgb:26 26 29;
59
+ --shadow-rgb:0 0 0;--inner-highlight:rgb(255 255 255/0.55);
60
+ --ease-out-expo:cubic-bezier(0.16,1,0.3,1);
61
+ --ease-out-back:cubic-bezier(0.34,1.2,0.64,1);
62
+ --ease-spring:cubic-bezier(0.34,1.56,0.64,1);
63
+ --ease-out-quart:cubic-bezier(0.25,1,0.5,1);
64
+ font-family:'DIN Round',system-ui,-apple-system,sans-serif;
65
+ background:var(--surface-1);color:var(--ink);
66
+ font-feature-settings:'cv02','cv03','cv04','cv11';
67
+ -webkit-font-smoothing:antialiased;
68
+ }
69
+ /* the six-step type scale (rem, follows the host base) */
70
+ .lks-ui .lks-t-caption{font-size:.75rem}.lks-ui .lks-t-label{font-size:.825rem}
71
+ .lks-ui .lks-t-body{font-size:.875rem}.lks-ui .lks-t-lead{font-size:1rem}
72
+ .lks-ui .lks-t-title{font-size:1.125rem}.lks-ui .lks-t-hero{font-size:1.5rem}
73
+
74
+ /* ── the column ladder: no borders, depth by surface step (v0.6) ── */
75
+ .lks-ui .lks14-rail{background:var(--surface-rail);border-right:none}
76
+ .lks-ui .lks14-chat{background:var(--surface-1);border-right:none}
77
+ .lks-ui .lks14-note{background:var(--surface-2)}
78
+ .lks-ui .lks14-colhead{color:var(--ink-muted);border-bottom:1px solid var(--border-faint);letter-spacing:.02em;text-transform:none;font-size:.825rem}
79
+
80
+ /* ── the floating app-header over the right half (blur + gradient fade) ── */
81
+ .lks-ui .lks14-appheader{position:relative;z-index:30;flex:none;display:flex;align-items:center;height:46px;padding:0 14px;gap:10px}
82
+ .lks-ui .lks14-appheader::before{content:'';position:absolute;inset:0;z-index:-1;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);
83
+ -webkit-mask:linear-gradient(to bottom,black 0%,black 65%,transparent 100%);mask:linear-gradient(to bottom,black 0%,black 65%,transparent 100%);
84
+ background:linear-gradient(to bottom,rgb(var(--surface-1-rgb)/0.85) 0%,rgb(var(--surface-1-rgb)/0.55) 60%,rgb(var(--surface-1-rgb)/0.15) 85%,transparent 100%)}
85
+ .lks-ui .lks-hdr-title{flex:1;min-width:0;font-size:.825rem;font-weight:700;color:var(--ink-strong);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
86
+ .lks-ui .lks-hdr-xp{display:flex;align-items:center;gap:5px;flex:none}
87
+ .lks-ui .lks-hdr-xpbar{width:92px;height:10px;border-radius:999px;background:rgb(var(--ink-rgb)/0.08);overflow:hidden;box-shadow:inset 0 2px 3px rgb(var(--shadow-rgb)/0.3)}
88
+ .lks-ui .lks-hdr-xpbar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--gold-light));border-radius:999px;transition:width .5s var(--ease-out-expo)}
89
+ .lks-ui .lks-hdr-stat{display:inline-flex;align-items:center;gap:4px;font-size:.825rem;font-weight:700;color:var(--ink);flex:none}
90
+ .lks-ui .lks-hdr-streak{color:var(--review)}
91
+
92
+ /* ── 3D push-down buttons (the signature style) ── */
93
+ .lks-ui .lks-btn{border-radius:1rem;font-weight:700;transition:all .15s var(--ease-spring)}
94
+ .lks-ui .lks-btn.primary{background:var(--brand);color:#fff;box-shadow:0 4px 0 0 var(--brand-dark)}
95
+ .lks-ui .lks-btn.primary:hover{background:var(--brand-light)}
96
+ .lks-ui .lks-btn.primary:active{transform:translateY(2px);box-shadow:0 1px 0 0 var(--brand-dark)}
97
+ .lks-ui .lks-btn.primary:disabled{background:rgb(var(--ink-rgb)/0.15);color:var(--ink-faint);box-shadow:none;transform:none;cursor:not-allowed}
98
+ .lks-ui .lks-btn.ghost{background:rgb(var(--ink-rgb)/0.06);color:var(--ink);border:none;box-shadow:0 3px 0 0 rgb(var(--shadow-rgb)/0.2)}
99
+ .lks-ui .lks-btn.ghost:hover{color:var(--ink-strong);background:rgb(var(--ink-rgb)/0.1)}
100
+ .lks-ui .lks-btn.ghost:active{transform:translateY(2px);box-shadow:0 1px 0 0 rgb(var(--shadow-rgb)/0.2)}
101
+
102
+ /* ── chat stream: bubbles + typing dots ── */
103
+ .lks-ui .lks14-msg-user{background:rgb(var(--ink-rgb)/0.04);border-radius:1rem 1rem .375rem 1rem;padding:.625rem 1rem;color:var(--ink-strong);animation:lks-msg-enter .2s var(--ease-out-expo)}
104
+ .lks-ui .lks14-msg-assistant{background:var(--surface-2);border:1px solid var(--border-faint);border-radius:1rem;padding:1rem;box-shadow:0 1px 3px -1px rgb(var(--shadow-rgb)/0.08);animation:lks-msg-enter .2s var(--ease-out-expo);font-size:.875rem;line-height:1.65}
105
+ .lks-ui .lks14-msg.streaming{opacity:1;border-style:dashed}
106
+ @keyframes lks-msg-enter{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
107
+ .lks-ui .lks14-thinking{gap:5px}
108
+ @keyframes lks-typing-dot{0%,60%,100%{opacity:.3;transform:translateY(0)}30%{opacity:1;transform:translateY(-4px)}}
109
+ .lks-ui .lks14-thinking i{width:6px;height:6px;border-radius:999px;background:var(--brand);display:inline-block;animation:lks-typing-dot 1.2s ease-in-out infinite}
110
+ .lks-ui .lks14-thinking i:nth-child(2){animation-delay:.15s}
111
+ .lks-ui .lks14-thinking i:nth-child(3){animation-delay:.3s}
112
+ /* quiz options wear the neutral pill + brand hover */
113
+ .lks-ui .lks-qcard-opt{background:rgb(var(--ink-rgb)/0.05);border:2px solid transparent;border-radius:.75rem;color:var(--ink)}
114
+ .lks-ui .lks-qcard-opt:not(:disabled):hover{border-color:var(--brand)}
115
+ .lks-ui .lks-qcard-opt.right{border-color:var(--brand);color:var(--brand-light);background:rgb(var(--brand-rgb)/0.08)}
116
+ .lks-ui .lks-qcard-opt.wrong{border-color:var(--warning);color:var(--warning-light);background:var(--warning-tint)}
117
+ .lks-ui .lks-qcard-next{background:var(--brand);color:#fff;box-shadow:0 3px 0 0 var(--brand-dark);border-radius:1rem}
118
+ .lks-ui .lks-qcard-next:active{transform:translateY(2px);box-shadow:0 1px 0 0 var(--brand-dark)}
119
+ .lks-ui .lks-qcard-action{background:rgb(var(--ink-rgb)/0.06);border:none;color:var(--ink)}
120
+ .lks-ui .lks-qcard-action:hover{color:var(--ink-strong);background:rgb(var(--ink-rgb)/0.1)}
121
+
122
+ /* ── the composer capsule (claude.ai style) + circular 3D send ── */
123
+ .lks-ui .lks14-composer{border-top:none;background:linear-gradient(to bottom,transparent 0%,var(--surface-1) 40%)}
124
+ .lks-ui .lks14-composer-card{flex:1;display:flex;align-items:flex-end;gap:8px;background:rgb(var(--ink-rgb)/0.05);border-radius:1rem;padding:.625rem .75rem .5rem .875rem;transition:background .15s}
125
+ .lks-ui .lks14-composer-card:focus-within{background:rgb(var(--ink-rgb)/0.07)}
126
+ .lks-ui .lks14-composertext{background:transparent;border:none;box-shadow:none;color:var(--ink-strong);font-size:.875rem}
127
+ .lks-ui .lks14-composertext:focus{border-color:transparent}
128
+ .lks-ui .lks14-composertext::placeholder{color:var(--ink-faint)}
129
+ .lks-ui .lks-btn-send{width:36px;height:36px;border-radius:999px !important;padding:0 !important;display:inline-flex;align-items:center;justify-content:center;flex:none;background:var(--brand);color:#fff;box-shadow:0 3px 0 0 var(--brand-dark);border:none;cursor:pointer;transition:all .15s var(--ease-spring)}
130
+ .lks-ui .lks-btn-send:hover{background:var(--brand-light)}
131
+ .lks-ui .lks-btn-send:active{transform:translateY(2px);box-shadow:0 1px 0 0 var(--brand-dark)}
132
+ .lks-ui .lks-btn-send:disabled{background:rgb(var(--ink-rgb)/0.15);color:var(--ink-faint);box-shadow:none;transform:none;cursor:not-allowed}
133
+
134
+ /* ── toast capsules on the upstream tokens ── */
135
+ .lks-ui .lks-toast{background:var(--surface-0);color:var(--ink-strong);border:1px solid var(--border);box-shadow:0 8px 24px -4px rgb(var(--shadow-rgb)/0.18),0 2px 6px -2px rgb(var(--shadow-rgb)/0.12)}
136
+ .lks-ui .lks-toast .lks-toast-glyph.ok{color:var(--brand)}
137
+ .lks-ui .lks-toast .lks-toast-glyph.warn{color:var(--gold)}
138
+ .lks-ui .lks-toast .lks-toast-glyph.err{color:var(--warning)}
139
+ .lks-ui .lks-toast .lks-toast-glyph.info{color:var(--accent)}
140
+ .lks-ui .lks-toast-action{color:var(--brand)}
141
+
142
+ /* ── rail: signpost section heads + due box ── */
143
+ .lks-ui .lks14-sechead{background:rgb(var(--surface-rail-rgb)/0.55);backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px);border:1px solid rgb(var(--gold-rgb)/0.18);border-top-color:rgb(var(--gold-rgb)/0.35);border-radius:.75rem;padding:7px 10px;margin:12px 0 6px;box-shadow:0 2px 8px rgb(var(--shadow-rgb)/0.35),inset 0 1px 0 rgb(255 255 255/0.06);color:var(--gold-light)}
144
+ .lks-ui .lks14-secnum{background:rgb(var(--gold-rgb)/0.15);color:var(--gold-light)}
145
+ .lks-ui .lks14-node{color:var(--ink);border-radius:.75rem}
146
+ .lks-ui .lks14-node:hover{background:rgb(var(--ink-rgb)/0.05)}
147
+ .lks-ui .lks14-node.focus{background:rgb(var(--accent-rgb)/0.1);outline:1px solid rgb(var(--accent-rgb)/0.25)}
148
+ .lks-ui .lks14-node[aria-disabled='true']{color:var(--ink-faint)}
149
+ .lks-ui .lks14-search,.lks-ui .lks-set-select{background:rgb(var(--ink-rgb)/0.05);border:1px solid var(--border-faint);color:var(--ink-strong)}
150
+ .lks-ui .lks14-search:focus{border-color:var(--accent)}
151
+ .lks-ui .lks14-duebox{background:var(--review-tint);border:1px solid rgb(var(--review-rgb)/0.25);color:var(--ink)}
152
+ .lks-ui .lks14-over{color:var(--warning-light)}
153
+ .lks-ui .lks14-tag.weak{color:var(--gold-light)}
154
+ .lks-ui .lks14-masterybar{background:rgb(var(--ink-rgb)/0.08)}
155
+ .lks-ui .lks14-masterybar i{background:var(--gold)}
156
+ .lks-ui .lks14-bar{background:rgb(var(--ink-rgb)/0.08)}
157
+ .lks-ui .lks14-bar i{background:var(--gold)}
158
+ .lks-ui .lks14-pill{color:var(--ink-muted);border-radius:999px}
159
+ .lks-ui .lks14-pill.on{background:rgb(var(--accent-rgb)/0.12);color:var(--accent-light)}
160
+
161
+ /* ── notebook: teach prose + view tabs ── */
162
+ .lks-ui .lks14-viewtab{color:var(--ink-muted);border-radius:999px}
163
+ .lks-ui .lks14-viewtab.on{background:rgb(var(--ink-rgb)/0.08);color:var(--ink-strong)}
164
+ .lks-ui .lks14-prose{color:var(--ink);font-size:.9375rem;line-height:1.65}
165
+ .lks-ui .lks14-prose h1,.lks-ui .lks-note-text h1{font-size:1.25rem;font-weight:800;color:var(--ink-strong)}
166
+ .lks-ui .lks14-prose h2,.lks-ui .lks-note-text h2{font-size:1.1rem;font-weight:700;color:var(--ink-strong);border-bottom:1px solid var(--border);padding-bottom:.3em}
167
+ .lks-ui .lks14-prose h3,.lks-ui .lks-note-text h3{font-size:1rem;font-weight:700;color:var(--ink)}
168
+ .lks-ui .lks14-prose pre,.lks-ui .lks-note-text pre{background:var(--surface-0);border:1px solid var(--border);color:var(--ink)}
169
+ .lks-ui .lks14-prose code,.lks-ui .lks-note-text code{background:rgb(var(--surface-3-rgb)/0.6);color:var(--accent-light)}
170
+ .lks-ui .lks14-prose blockquote{border-left:3px solid var(--accent);background:rgb(var(--accent-rgb)/0.06);color:var(--ink-muted)}
171
+ .lks-ui .lks14-prose table thead{border-bottom:2px solid var(--brand)}
172
+ .lks-ui .lks14-prose th{color:var(--ink-strong)}
173
+ .lks-ui .lks14-prose tbody tr:nth-child(odd){background:rgb(var(--surface-3-rgb)/0.3)}
174
+ .lks-ui .lks14-prose td,.lks-ui .lks14-prose th{border-color:var(--border)}
175
+ .lks-ui .lks-note{background:var(--surface-1);border:1px solid var(--border-faint)}
176
+ .lks-ui .lks-note .lks-note-q{border-left-color:var(--gold)}
177
+ .lks-ui .lks14-zoneh{color:var(--ink-muted)}
178
+ .lks-ui mark.lks-hl{background:rgb(var(--gold-rgb)/0.25);border-bottom:2px solid var(--gold)}
179
+
180
+ /* ── artifact + rate + proposal cards on surface-card language ── */
181
+ .lks-ui .lks-acard,.lks-ui .lks-qcard{background:var(--surface-2);border:1px solid var(--border-faint);border-radius:1rem;box-shadow:0 1px 3px -1px rgb(var(--shadow-rgb)/0.08)}
182
+ .lks-ui .lks-acard-head,.lks-ui .lks-qcard-head{color:var(--ink-muted)}
183
+ .lks-ui .lks-acard-table th{background:transparent;border-bottom:2px solid var(--brand);color:var(--ink-strong)}
184
+ .lks-ui .lks-acard-table td{border-bottom:1px solid var(--border)}
185
+ .lks-ui .lks-acard-code{background:var(--surface-0);border:1px solid var(--border)}
186
+ .lks-ui .lks-acard-line i{color:var(--ink-faint)}
187
+ .lks-ui .lks-acard-note{border-left-color:var(--accent);color:var(--ink-muted)}
188
+ .lks-ui .lks-acard-modal-body{background:var(--surface-0);border-color:var(--border)}
189
+ .lks-ui .lks-acard-guess-opt{background:rgb(var(--ink-rgb)/0.05);border:2px solid transparent;color:var(--ink)}
190
+ .lks-ui .lks-acard-guess-opt:not(:disabled):hover{border-color:var(--accent)}
191
+ .lks-ui .lks-acard-guess-opt.picked{border-color:var(--accent);background:rgb(var(--accent-rgb)/0.1);color:var(--accent-light)}
192
+ .lks-ui .lks-ratecard{background:rgb(var(--review-rgb)/0.08);border:1px solid rgb(var(--review-rgb)/0.25)}
193
+ .lks-ui .lks-ratecard-title{color:var(--ink-strong)}
194
+ .lks-ui .lks-ratecard-opt{background:rgb(var(--ink-rgb)/0.05);border:2px solid transparent;color:var(--ink)}
195
+ .lks-ui .lks-ratecard-opt:not(:disabled):hover{border-color:var(--brand)}
196
+ .lks-ui .lks-ratecard-opt.best{background:var(--gold);color:#3A2E00;box-shadow:0 3px 0 0 var(--gold-dark);border-color:transparent}
197
+ .lks-ui .lks-ratecard-opt.best:active{transform:translateY(2px);box-shadow:0 1px 0 0 var(--gold-dark)}
198
+ .lks-ui .lks-propbanner{background:rgb(var(--gold-rgb)/0.06);border:1px solid rgb(var(--gold-rgb)/0.3);color:var(--ink)}
199
+ .lks-ui .lks-viewtab-badge{background:var(--brand);color:#fff}
200
+
201
+ /* ── the empty states + readbar + switcher ── */
202
+ .lks-ui .lks14-empty{color:var(--ink-muted)}
203
+ .lks-ui .lks-readbar-cur{border-left-color:var(--accent);color:var(--ink-muted)}
204
+ .lks-ui .lks14-switch{background:rgb(var(--ink-rgb)/0.06);border:1px solid var(--border-faint)}
205
+ .lks-ui .lks14-switch-btn.on{background:var(--brand);color:#fff}
206
+ .lks-ui .lks14-searchpanel{background:var(--surface-0);border:1px solid var(--border);box-shadow:0 8px 24px -4px rgb(var(--shadow-rgb)/0.18)}
207
+ .lks-ui .lks14-searchrow:hover{background:rgb(var(--ink-rgb)/0.05)}
208
+ .lks-ui .lks14-searchrow-snip{color:var(--ink-muted)}
209
+ .lks-ui .lks-propcard-err{color:var(--warning-light)}
210
+ .lks-ui .lks14-thinking{color:var(--ink-muted)}
211
+
212
+ /* ══════════ P9b: the balloon course map (upstream v0.28 MapRail, static path) ══════════ */
213
+ .lks-ui{--exam-locked-mix:#A7A2B2}
214
+ /* the sky: upstream's canvas presets reduced to layered gradients (essence port;
215
+ deterministic per-course via pickSky) */
216
+ .lks-ui .lks-map{position:relative;min-height:100%;background-repeat:no-repeat}
217
+ .lks-ui .lks-sky-day{background-image:
218
+ radial-gradient(1.5px 1.5px at 18% 12%,rgb(255 255 255/0.5),transparent 100%),
219
+ radial-gradient(1px 1px at 64% 8%,rgb(255 255 255/0.35),transparent 100%),
220
+ radial-gradient(1px 1px at 82% 22%,rgb(255 255 255/0.3),transparent 100%),
221
+ radial-gradient(120px 60px at 78% 6%,rgb(124 172 255/0.08),transparent 100%),
222
+ linear-gradient(to bottom,#16233F 0%,#0F1B33 55%,var(--surface-rail) 100%)}
223
+ .lks-ui .lks-sky-dusk{background-image:
224
+ radial-gradient(1.5px 1.5px at 24% 10%,rgb(255 255 255/0.45),transparent 100%),
225
+ radial-gradient(1px 1px at 70% 16%,rgb(255 255 255/0.3),transparent 100%),
226
+ radial-gradient(140px 70px at 22% 10%,rgb(255 160 90/0.07),transparent 100%),
227
+ linear-gradient(to bottom,#2A2140 0%,#1D1A38 50%,var(--surface-rail) 100%)}
228
+ .lks-ui .lks-sky-night{background-image:
229
+ radial-gradient(1.5px 1.5px at 12% 18%,rgb(255 255 255/0.55),transparent 100%),
230
+ radial-gradient(1px 1px at 38% 6%,rgb(255 255 255/0.4),transparent 100%),
231
+ radial-gradient(1px 1px at 58% 24%,rgb(255 255 255/0.35),transparent 100%),
232
+ radial-gradient(1.5px 1.5px at 88% 10%,rgb(255 255 255/0.5),transparent 100%),
233
+ radial-gradient(1px 1px at 76% 30%,rgb(255 255 255/0.3),transparent 100%),
234
+ radial-gradient(90px 90px at 84% 8%,rgb(200 214 255/0.06),transparent 100%),
235
+ linear-gradient(to bottom,#0B1024 0%,#0A0D1C 55%,var(--surface-rail) 100%)}
236
+ .lks-ui .lks-mapsec{padding:12px 8px 4px}
237
+ /* the signpost (map-signpost verbatim): frosted board with a gold rim */
238
+ .lks-ui .lks-signpost{display:flex;align-items:center;gap:8px;width:100%;padding:6px 10px;border-radius:8px;font:inherit;cursor:pointer;text-align:left;
239
+ background:rgb(var(--surface-rail-rgb)/0.55);backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px);
240
+ border:1px solid rgb(var(--gold-rgb)/0.18);border-top-color:rgb(var(--gold-rgb)/0.35);
241
+ box-shadow:0 2px 8px rgb(var(--shadow-rgb)/0.35),inset 0 1px 0 rgb(255 255 255/0.06)}
242
+ .lks-ui .lks-signpost-num{width:24px;height:24px;border-radius:999px;background:var(--gold);color:#201500;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 0 0 2px rgb(var(--gold-rgb)/0.4)}
243
+ .lks-ui .lks-signpost-title{flex:1;font-size:12.5px;font-weight:700;color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 1px 2px rgba(0,0,0,0.7)}
244
+ .lks-ui .lks-signpost-caret{color:rgb(var(--ink-rgb)/0.5);font-size:10px}
245
+ .lks-ui .lks-mapfield{position:relative}
246
+ .lks-ui .lks-mapropes{position:absolute;inset:0;width:100%;pointer-events:none;overflow:visible}
247
+ .lks-ui .lks-mapnode{position:absolute;width:110px;display:flex;flex-direction:column;align-items:center;
248
+ animation:lks-balloon-bob var(--bob-duration,6s) ease-in-out infinite;animation-delay:var(--bob-delay,0s)}
249
+ @keyframes lks-balloon-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
250
+ @keyframes lks-path-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
251
+ @media (prefers-reduced-motion: reduce){.lks-ui .lks-mapnode{animation:none}.lks-ui .lks-bubble-available{animation:none}}
252
+ /* the bubbles (lesson-bubble verbatim): 3D sphere lighting + ::before inner highlight */
253
+ .lks-ui .lks-bubble{position:relative;width:56px;height:56px;border-radius:999px;border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:all .2s var(--ease-out-back);background-position:30% 25%}
254
+ .lks-ui .lks-bubble::before{content:'';position:absolute;top:12%;left:22%;width:38%;height:28%;border-radius:50%;
255
+ background:radial-gradient(ellipse at center,var(--inner-highlight) 0%,rgb(255 255 255/0) 70%);pointer-events:none;z-index:1}
256
+ .lks-ui .lks-bubble[aria-disabled='true']{cursor:not-allowed}
257
+ .lks-ui .lks-bubble.selected{outline:4px solid var(--accent);outline-offset:3px}
258
+ .lks-ui .lks-bubble-glyph{position:relative;z-index:2;color:#fff;display:inline-flex;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.4))}
259
+ .lks-ui .lks-bubble-glyph.dim{opacity:.5}
260
+ .lks-ui .lks-bubble-ring{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg);pointer-events:none}
261
+ .lks-ui .lks-bubble-due{position:absolute;top:-4px;right:-4px;width:16px;height:16px;border-radius:999px;background:var(--review);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 2px var(--surface-rail);z-index:3}
262
+ .lks-ui .lks-bubble-name{margin-top:4px;max-width:120px;font-size:11px;font-weight:700;color:#fff;background:rgb(var(--brand-rgb)/0.9);padding:2px 6px;border-radius:6px;text-align:center;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
263
+ /* locked: heavy sunken stone */
264
+ .lks-ui .lks-bubble-locked{background:radial-gradient(circle at 35% 30%,var(--surface-3) 0%,var(--surface-2) 60%,var(--surface-1) 100%);
265
+ box-shadow:inset 0 3px 6px rgb(var(--shadow-rgb)/0.5),inset 0 -2px 4px rgb(255 255 255/0.04),0 2px 4px rgb(var(--shadow-rgb)/0.3)}
266
+ .lks-ui .lks-bubble-locked::before{background:radial-gradient(ellipse at center,rgb(255 255 255/0.12) 0%,rgb(255 255 255/0) 70%)}
267
+ /* available: bright glowing sphere, your move */
268
+ .lks-ui .lks-bubble-available{background:radial-gradient(circle at 32% 28%,var(--brand-light) 0%,var(--brand) 45%,var(--brand-dark) 100%);
269
+ box-shadow:0 0 0 4px var(--brand-ring),0 0 16px var(--brand-glow),0 4px 10px rgb(var(--shadow-rgb)/0.25)}
270
+ .lks-ui .lks-bubble-available:hover{transform:translateY(-3px) scale(1.03);
271
+ box-shadow:0 0 0 5px rgb(var(--brand-rgb)/0.2),0 0 22px rgb(var(--brand-rgb)/0.5),0 6px 14px rgb(var(--shadow-rgb)/0.3)}
272
+ .lks-ui .lks-bubble-available:active{transform:translateY(2px) scale(0.97);
273
+ box-shadow:0 0 0 3px var(--brand-ring),0 0 10px rgb(var(--brand-rgb)/0.3),0 2px 6px rgb(var(--shadow-rgb)/0.2)}
274
+ @keyframes lks-bubble-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.04)}}
275
+ .lks-ui .lks-bubble-available{animation:lks-bubble-pulse 2.4s ease-in-out infinite}
276
+ /* in_progress: blue in motion */
277
+ .lks-ui .lks-bubble-in-progress{background:radial-gradient(circle at 32% 28%,var(--accent-light) 0%,var(--accent) 45%,var(--accent-dark) 100%);
278
+ box-shadow:0 0 0 4px var(--accent-ring),0 0 14px var(--accent-glow),0 4px 10px rgb(var(--shadow-rgb)/0.25)}
279
+ .lks-ui .lks-bubble-in-progress:hover{transform:translateY(-2px) scale(1.03);
280
+ box-shadow:0 0 0 5px rgb(var(--accent-rgb)/0.2),0 0 20px rgb(var(--accent-rgb)/0.45),0 6px 14px rgb(var(--shadow-rgb)/0.3)}
281
+ /* mastered: gold crown halo, scaled up */
282
+ .lks-ui .lks-bubble-mastered{background:radial-gradient(circle at 32% 28%,var(--gold-light) 0%,var(--gold) 45%,var(--gold-dark) 100%);
283
+ box-shadow:0 0 0 4px var(--gold-ring),0 0 22px var(--gold-glow),0 0 40px rgb(var(--gold-rgb)/0.15),0 4px 12px rgb(var(--shadow-rgb)/0.25);transform:scale(1.1)}
284
+ .lks-ui .lks-bubble-mastered::before{background:radial-gradient(ellipse at center,rgb(255 255 255/0.7) 0%,rgb(255 255 255/0) 70%)}
285
+ .lks-ui .lks-bubble-mastered:hover{transform:scale(1.13) translateY(-2px);
286
+ box-shadow:0 0 0 5px rgb(var(--gold-rgb)/0.25),0 0 28px rgb(var(--gold-rgb)/0.55),0 0 50px rgb(var(--gold-rgb)/0.2),0 6px 16px rgb(var(--shadow-rgb)/0.3)}
287
+ /* exam: the section boss */
288
+ .lks-ui .lks-exam{background:radial-gradient(circle at 32% 28%,var(--exam-light) 0%,var(--exam) 45%,var(--exam-dark) 100%);
289
+ box-shadow:0 0 0 4px var(--exam-ring),0 0 16px var(--exam-glow),0 4px 10px rgb(var(--shadow-rgb)/0.25)}
290
+ .lks-ui .lks-exam-locked{background:radial-gradient(circle at 35% 30%,var(--exam-locked) 0%,var(--exam-locked-mix) 60%,var(--surface-1) 100%);
291
+ box-shadow:inset 0 3px 6px rgb(var(--shadow-rgb)/0.5),inset 0 -2px 4px rgb(255 255 255/0.04),0 2px 4px rgb(var(--shadow-rgb)/0.3)}
292
+ .lks-ui .lks-exam-passed{background:radial-gradient(circle at 32% 28%,rgb(var(--exam-light-rgb,192 132 252)) 0%,var(--exam-light) 40%,var(--exam) 100%);
293
+ box-shadow:0 0 0 4px var(--gold-ring),0 0 18px var(--exam-glow),0 0 28px rgb(var(--gold-rgb)/0.15),0 4px 12px rgb(var(--shadow-rgb)/0.25)}
294
+ /* the floating rail head card (upstream map-header card) */
295
+ .lks-ui .lks14-railhead{background:rgb(var(--surface-rail-rgb)/0.55);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgb(255 255 255/0.06);border-radius:12px;padding:8px 10px}
296
+ .lks-ui .lks14-railtitle{color:#fff;font-weight:800;font-size:.875rem;text-shadow:0 1px 3px rgba(0,0,0,0.6)}
297
+ .lks-ui .lks14-railsub{color:rgb(var(--ink-rgb)/0.6)}
298
+ .lks-ui .lks14-masterybar{height:10px;background:rgb(0 0 0/0.4);border-radius:999px;box-shadow:inset 0 0 0 1px rgb(255 255 255/0.1);overflow:hidden}
299
+ .lks-ui .lks14-masterybar i{background:var(--brand)}
300
+ .lks-ui .lks14-masterybar.gold i{background:var(--gold)}
301
+ .lks-ui .lks14-search{background:rgb(0 0 0/0.25);border:1px solid rgb(255 255 255/0.15);color:var(--ink-strong);border-radius:10px}
302
+ .lks-ui .lks14-search:focus{border-color:var(--brand);outline:none}
303
+ .lks-ui .lks14-search::placeholder{color:rgb(var(--ink-rgb)/0.35)}
304
+ `;
305
+ //#endregion
9
306
  //#region src/client/styles.ts
10
307
  /**
308
+ * The study stylesheet. The center-takeover visibility follows the stardeck
309
+ * panel doctrine: the shell view is display:none until the `<html>` active
310
+ * attribute flips, and the host conversation column's own children are hidden
311
+ * behind `!important` so React never fights the takeover.
312
+ */
313
+ /**
11
314
  * Style injection for the study surfaces: one `<style>` element carrying the
12
315
  * `.lks14-*` panel classes (the sidebar-entry center-takeover, arranged like
13
316
  * upstream LookatStudy) plus the shared `.lks-*` classes the host-native
@@ -21,12 +324,6 @@ window.__ModuleLoader__.load({
21
324
  */
22
325
  /** Marker attribute proving the stylesheet is already in the document. */
23
326
  const STYLE_ID = "data-dsh-plugin-lookatstudy";
24
- /**
25
- * The study stylesheet. The center-takeover visibility follows the stardeck
26
- * panel doctrine: the shell view is display:none until the `<html>` active
27
- * attribute flips, and the host conversation column's own children are hidden
28
- * behind `!important` so React never fights the takeover.
29
- */
30
327
  const STUDY_CSS = `
31
328
  /* State inks: small colored text must clear WCAG AA (4.5:1), but the host's
32
329
  raw state tokens (success/warn primaries) sit at 2.3–2.6:1 on light
@@ -38,7 +335,7 @@ window.__ModuleLoader__.load({
38
335
  every surface family that consumes the inks: .lks-root (dock pill,
39
336
  settings) and .lks-tv (toolview cards, which render in the host
40
337
  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))}
338
+ .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
339
  .lks-root{font-family:var(--dsw-font-family)}
43
340
  .lks-root :where(button){font-family:var(--dsw-font-family);cursor:pointer;border:none;background:none;padding:0}
44
341
 
@@ -59,9 +356,11 @@ html[data-dsh-lookatstudy-active] .lks14-shell-view{display:flex;flex-direction:
59
356
  html[data-dsh-lookatstudy-active] [data-pane='conversation'] > :not([data-dsh-lookatstudy-view]),
60
357
  html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatstudy-view]){display:none !important}
61
358
  [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)}
359
+ .lks14{position:relative;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
63
360
  .lks14 button{font-family:var(--dsw-font-family);cursor:pointer}
64
361
  .lks14-body{display:flex;flex:1;min-width:0;min-height:0}
362
+ .lks14-righthalf{flex:1;min-width:0;display:flex;flex-direction:column;min-height:0}
363
+ .lks14-row{flex:1;min-height:0;display:flex}
65
364
  .lks14-col{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
66
365
  .lks14-colhead{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1)}
67
366
 
@@ -78,22 +377,6 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
78
377
  .lks14-duebox{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;margin:8px 0;font-size:12.5px}
79
378
  .lks14-dueitem{display:flex;justify-content:space-between;gap:6px;padding:2px 0;color:var(--dsw-alias-label-secondary)}
80
379
  .lks14-over{color:var(--dsw-alias-state-error-primary)}
81
- .lks14-sechead{display:flex;align-items:center;gap:6px;width:100%;background:none;border:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;font-weight:600;padding:6px 2px;cursor:pointer;text-align:left}
82
- .lks14-sechead:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
83
- .lks14-secnum{flex:none;width:18px;height:18px;border-radius:5px;background:var(--dsw-alias-bg-layer-2);display:inline-flex;align-items:center;justify-content:center;font-size:11px}
84
- .lks14-secheadt{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
85
- .lks14-node{display:flex;align-items:center;gap:6px;width:100%;background:none;border:none;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:5px 6px;border-radius:8px;cursor:pointer;text-align:left}
86
- .lks14-node:hover{background:var(--dsw-alias-bg-layer-1)}
87
- .lks14-node:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
88
- .lks14-node.focus{background:var(--dsw-alias-bg-layer-2)}
89
- .lks14-node[aria-disabled='true']{color:var(--dsw-alias-label-tertiary);cursor:default}
90
- .lks14-g{flex:none;display:inline-flex}
91
- .lks14-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
- .lks14-tag{flex:none;display:inline-flex;align-items:center;gap:2px;font-size:10.5px;border-radius:6px;padding:1px 4px}
93
- .lks14-tag.weak{color:var(--dsw-alias-state-warn-primary)}
94
- .lks14-bar{flex:none;width:26px;height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
95
- .lks14-bar i{display:block;height:100%;background:var(--dsw-alias-business-primary);transform-origin:left}
96
- .lks14-pct{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
97
380
  .lks14-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;padding:18px 6px;line-height:1.7}
98
381
  .lks14-import{margin:8px 0}
99
382
  .lks14-inputrow{display:flex;gap:6px}
@@ -152,6 +435,153 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
152
435
  .lks14-zone{margin-bottom:12px}
153
436
  .lks14-zoneh{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
154
437
 
438
+
439
+ /* toast capsules (P6, upstream Toast port): top-center under the panel head,
440
+ container inert to pointers, items auto; exit is an animation handshake —
441
+ reduced-motion keeps a .01ms duration so the end event still fires. */
442
+ .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%)}
443
+ .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}
444
+ .lks-toast.exiting{animation:lks-toast-out .18s ease forwards}
445
+ .lks-toast .lks-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
446
+ .lks-toast .lks-toast-glyph{flex:none}
447
+ .lks-toast .lks-toast-glyph.ok{color:var(--lks-ok-ink)}
448
+ .lks-toast .lks-toast-glyph.warn{color:var(--lks-warn-ink)}
449
+ .lks-toast .lks-toast-glyph.err{color:var(--lks-err-ink)}
450
+ .lks-toast .lks-toast-glyph.info{color:var(--dsw-alias-state-business-primary)}
451
+ .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}
452
+ .lks-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)}
453
+ .lks-toast-action:focus-visible,.lks-toast-close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
454
+ .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}
455
+ .lks-toast-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
456
+ @keyframes lks-toast-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
457
+ @keyframes lks-toast-out{to{opacity:0;transform:translateY(-6px)}}
458
+ @media (prefers-reduced-motion:reduce){.lks-toast{animation-duration:.01ms}}
459
+
460
+
461
+
462
+
463
+
464
+
465
+
466
+
467
+ /* the companion creature (P7): bottom-right of the panel; gentle bob idle,
468
+ bounce while talking, pop on celebration; reduced-motion = static (a11y). */
469
+ .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}
470
+ .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))}
471
+ .lks-companion.mood-talking{animation:lks-comp-talk .6s ease-in-out infinite}
472
+ .lks-companion.mood-celebrating{animation:lks-comp-pop .5s cubic-bezier(.34,1.56,.64,1) 3}
473
+ .lks-companion.mood-encouraging{animation:lks-comp-nudge 1.2s ease-in-out 2}
474
+ @keyframes lks-comp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
475
+ @keyframes lks-comp-talk{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-7px) scale(1.06)}}
476
+ @keyframes lks-comp-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}
477
+ @keyframes lks-comp-nudge{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px) rotate(-4deg)}75%{transform:translateX(5px) rotate(4deg)}}
478
+ @media (prefers-reduced-motion:reduce){.lks-companion{animation:none}}
479
+
480
+ /* P5: proposal banner (ConfirmCard semantics) + thinking row + narrow switch */
481
+ .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)}
482
+ .lks-propbanner .lks-propbanner-why{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
483
+ .lks14-thinking{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--dsw-alias-label-secondary);padding:2px 2px}
484
+ .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}
485
+ .lks14-switch-btn{border-radius:999px;padding:4px 16px;font-size:13px;color:var(--dsw-alias-label-secondary);background:transparent}
486
+ .lks14-switch-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
487
+ .lks14-switch-btn.on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font-weight:600}
488
+ @container (max-width: 900px){
489
+ .lks14-switch{display:flex}
490
+ .lks14-body{flex-direction:column}
491
+ .lks14-rail{flex:none;max-height:32vh;border-right:none;border-bottom:1px solid var(--dsw-alias-border-l1)}
492
+ .lks14-chat{min-width:0}
493
+ .lks14-note{flex:1 1 auto;min-height:0}
494
+ .lks14-body[data-pane='rail'] .lks14-righthalf{display:none}
495
+ .lks14-body[data-pane='chat'] .lks14-rail,.lks14-body[data-pane='chat'] .lks14-note,
496
+ .lks14-body[data-pane='note'] .lks14-rail,.lks14-body[data-pane='note'] .lks14-chat{display:none}
497
+ .lks14-body[data-pane='rail'] .lks14-rail{display:flex;max-height:none;flex:1 1 auto}
498
+ .lks14-body[data-pane='note'] .lks14-note{display:flex}
499
+ }
500
+
501
+ /* rail search results panel (P4, upstream CourseSearchPanel) */
502
+ .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}
503
+ .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)}
504
+ .lks14-searchrow:last-child{border-bottom:none}
505
+ .lks14-searchrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
506
+ .lks14-searchrow-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}
507
+ .lks14-searchrow-course{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
508
+ .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}
509
+
510
+ /* review self-rating card (P3, upstream SelfRatingCard: three Memrise levels) */
511
+ .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}
512
+ .lks-ratecard-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary-bluish)}
513
+ .lks-ratecard-opts{display:flex;gap:6px}
514
+ .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}
515
+ .lks-ratecard-opt:hover{border-color:var(--dsw-alias-state-business-primary)}
516
+ .lks-ratecard-opt.again:hover{border-color:var(--dsw-alias-state-error-primary)}
517
+ .lks-ratecard-opt.best{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 86%,#000);color:#fff;border-color:transparent}
518
+ .lks-ratecard-opt.best:hover{filter:brightness(1.1)}
519
+ .lks-ratecard-opt:disabled{opacity:.6;cursor:default}
520
+ .lks14-dueitem{font:inherit;text-align:left}
521
+
522
+ /* selection-to-note (P2, upstream selection flow): the popover floats over
523
+ the prose; persisted highlights are soft amber marks. */
524
+ .lks14-prosewrap{position:relative}
525
+ .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%)}
526
+ .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}
527
+ .lks-quote-btn button:hover{background:var(--dsw-alias-interactive-bg-hover)}
528
+ .lks-quote-btn button + button{border-left:1px solid var(--dsw-alias-border-l1)}
529
+ 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}
530
+
531
+ /* artifact cards (P1b, upstream artifacts port): chat column + notebook zones */
532
+ .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}
533
+ .lks-acard-head{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
534
+ .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}
535
+ .lks-acard-expand:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
536
+ .lks-acard-table{border-collapse:collapse;display:block;max-width:100%;overflow-x:auto}
537
+ .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}
538
+ .lks-acard-table th{background:var(--dsw-alias-bg-layer-2)}
539
+ .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}
540
+ .lks-acard-line{display:block;white-space:pre}
541
+ .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}
542
+ .lks-acard-notes{display:flex;flex-direction:column;gap:5px}
543
+ .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}
544
+ .lks-acard-note b{color:var(--dsw-alias-label-primary);font-weight:600;margin-right:4px}
545
+ .lks-acard-diagram{overflow-x:auto;text-align:center}
546
+ .lks-acard-diagram svg{max-width:100%;height:auto}
547
+ .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}
548
+ .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}
549
+ .lks-acard-modal-title{display:flex;align-items:center;font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
550
+ .lks-acard-guess-prompt{font-size:13.5px;line-height:1.6;color:var(--dsw-alias-label-primary)}
551
+ .lks-acard-guess-opts{display:flex;gap:8px}
552
+ .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}
553
+ .lks-acard-guess-opt:not(:disabled):hover{border-color:var(--dsw-alias-state-business-primary)}
554
+ .lks-acard-guess-opt.picked{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
555
+ .lks-acard-guess-wait{font-size:12px;color:var(--dsw-alias-label-tertiary)}
556
+ .lks-acard-stage{margin-top:14px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
557
+ .lks-acard-stage .lks-acard{margin:0}
558
+ .lks14-zone .lks-acard{margin:0 0 8px}
559
+ /* notebook tab badge (unseen artifacts) */
560
+ .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}
561
+
562
+ /* practice card (P1, upstream QuizArtifact port): chat column, below the stream */
563
+ .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}
564
+ .lks-qcard-head{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
565
+ .lks-qcard-count{margin-left:auto;font-weight:400;color:var(--dsw-alias-label-tertiary)}
566
+ .lks-qcard-q{display:flex;flex-direction:column;gap:6px}
567
+ .lks-qcard-prompt{font-size:13.5px;line-height:1.55;color:var(--dsw-alias-label-primary)}
568
+ .lks-qcard-opts{display:flex;flex-direction:column;gap:4px}
569
+ .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}
570
+ .lks-qcard-opt:not(:disabled):hover{border-color:var(--dsw-alias-state-business-primary)}
571
+ .lks-qcard-opt:disabled{cursor:default}
572
+ .lks-qcard-opt.right{border-color:var(--dsw-alias-state-success-primary);color:var(--lks-ok-ink)}
573
+ .lks-qcard-opt.wrong{border-color:var(--dsw-alias-state-error-primary);color:var(--lks-err-ink)}
574
+ .lks-qcard-opt.dim{opacity:.75}
575
+ .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}
576
+ .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}
577
+ .lks-qcard-next:hover{filter:brightness(1.1)}
578
+ .lks-qcard-review .lks-qcard-q.review{padding:6px 0;border-bottom:1px dashed var(--dsw-alias-border-l1)}
579
+ .lks-qcard-review .lks-qcard-q.review.wrong .lks-qcard-prompt{color:var(--lks-err-ink)}
580
+ .lks-qcard-ans{font-size:12.5px;color:var(--dsw-alias-label-secondary)}
581
+ .lks-qcard-actions{display:flex;flex-wrap:wrap;gap:6px}
582
+ .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}
583
+ .lks-qcard-action:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
584
+
155
585
  /* note cards (shared: panel notebook + settings-era shapes) */
156
586
  .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
587
  .lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
@@ -235,7 +665,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
235
665
  if (existing !== null) return existing;
236
666
  const style = document.createElement("style");
237
667
  style.setAttribute(STYLE_ID, "");
238
- style.textContent = STUDY_CSS;
668
+ style.textContent = STUDY_CSS + UPSTREAM_CSS;
239
669
  document.head.appendChild(style);
240
670
  return style;
241
671
  }
@@ -346,6 +776,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
346
776
  fill: "currentColor"
347
777
  }));
348
778
  }
779
+ /** IconWarningOutline16 */
780
+ function IconWarningOutline16({ size = 16, className }) {
781
+ return (0, react.createElement)("svg", {
782
+ width: size,
783
+ height: size,
784
+ className,
785
+ viewBox: "0 0 16 16",
786
+ fill: "none",
787
+ xmlns: "http://www.w3.org/2000/svg"
788
+ }, (0, react.createElement)("path", {
789
+ d: "M6.3002 3.32843L7.69986 3.32843L7.69986 7.79657H6.3002L6.3002 3.32843Z",
790
+ fill: "currentColor"
791
+ }), (0, react.createElement)("path", {
792
+ d: "M6.3002 9.01935H7.69986V10.6711H6.3002V9.01935Z",
793
+ fill: "currentColor"
794
+ }), (0, react.createElement)("path", {
795
+ 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",
796
+ fill: "currentColor"
797
+ }));
798
+ }
349
799
  /**
350
800
  * Plugin-original companions for learning-domain glyphs the host set does not
351
801
  * carry (lock/star/book/crown/bolt/power/pin/flame) — authored here in the
@@ -556,6 +1006,30 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
556
1006
  });
557
1007
  this.refresh();
558
1008
  }
1009
+ /** Save a learner selection as a record-zone note (the highlight anchor is the quote). */
1010
+ async addUserNote(lessonId, quote) {
1011
+ await fetchJson("/lookatstudy/api/note/user", {
1012
+ method: "POST",
1013
+ headers: { "content-type": "application/json" },
1014
+ body: JSON.stringify({
1015
+ lessonId,
1016
+ quote
1017
+ })
1018
+ });
1019
+ this.refresh();
1020
+ }
1021
+ /** Record one SM-2 self-rating (1 again / 4 remembered / 5 mastered). */
1022
+ async recordReview(lessonId, quality) {
1023
+ await fetchJson("/lookatstudy/api/review", {
1024
+ method: "POST",
1025
+ headers: { "content-type": "application/json" },
1026
+ body: JSON.stringify({
1027
+ lessonId,
1028
+ quality
1029
+ })
1030
+ });
1031
+ this.refresh();
1032
+ }
559
1033
  /** Delete one notebook entry from a lesson's Cornell zones. */
560
1034
  async deleteNote(lessonId, noteId) {
561
1035
  await fetchJson("/lookatstudy/api/note/delete", {
@@ -643,6 +1117,8 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
643
1117
  searchLessons: studyStore.searchLessons.bind(studyStore),
644
1118
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
645
1119
  deleteNote: studyStore.deleteNote.bind(studyStore),
1120
+ addUserNote: studyStore.addUserNote.bind(studyStore),
1121
+ recordReview: studyStore.recordReview.bind(studyStore),
646
1122
  bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
647
1123
  };
648
1124
  }
@@ -822,6 +1298,12 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
822
1298
  }
823
1299
  //#endregion
824
1300
  //#region src/client/enhance.ts
1301
+ var enhance_exports = /* @__PURE__ */ __exportAll({
1302
+ enhanceCode: () => enhanceCode,
1303
+ enhanceDiagrams: () => enhanceDiagrams,
1304
+ enhanceMath: () => enhanceMath,
1305
+ enhanceRendered: () => enhanceRendered
1306
+ });
825
1307
  /**
826
1308
  * Post-render blackboard enhancement (SPEC Phase 2): math (KaTeX), code
827
1309
  * highlighting (shiki), and mermaid diagrams — every dependency loaded from a
@@ -1445,8 +1927,8 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1445
1927
  "col.tutor": "导师",
1446
1928
  "col.bb": "黑板",
1447
1929
  "pane.rail": "课程",
1448
- "pane.tutor": "导师",
1449
- "pane.bb": "黑板",
1930
+ "pane.chat": "对话",
1931
+ "pane.note": "黑板",
1450
1932
  "viewtab.teach": "讲解",
1451
1933
  "viewtab.cmap": "概念图",
1452
1934
  "viewtab.cmap.title": "本课概念之间的关系图",
@@ -1470,6 +1952,71 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1470
1952
  "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1471
1953
  "rail.empty.button": "导入",
1472
1954
  "composer.send": "发送",
1955
+ "quiz.card.title": "练习",
1956
+ "quiz.progress": "{cur}/{total}",
1957
+ "quiz.next": "下一题",
1958
+ "quiz.finish": "看成绩单",
1959
+ "quiz.score": "成绩:{correct}/{total}",
1960
+ "quiz.right": "答对了",
1961
+ "quiz.wrong": "答错了",
1962
+ "quiz.youChose": "你选了",
1963
+ "quiz.answer": "正确答案",
1964
+ "quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
1965
+ "quiz.action.explain-wrong": "讲讲错题",
1966
+ "quiz.action.retry": "再来一组",
1967
+ "quiz.action.go-deeper": "深入原理",
1968
+ "quiz.action.mark-mastered": "标记我掌握了",
1969
+ "quiz.action.markMastered.hint": "会让导师出综合题检验,通过即标记掌握",
1970
+ "quiz.action.next-topic": "下一个知识点",
1971
+ "artifact.lines": "第 {from}-{to} 行:",
1972
+ "artifact.expand": "放大查看",
1973
+ "artifact.guess.title": "猜一猜",
1974
+ "artifact.guess.pick": "我选「{label}」",
1975
+ "artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
1976
+ "artifact.sedimented": "有新的学习产物沉淀进笔记",
1977
+ "zone.artifacts": "AI 产物",
1978
+ "note.quote.ask": "提问这段",
1979
+ "note.quote.save": "加到笔记",
1980
+ "note.quote.template": "请讲解这段:「{text}」",
1981
+ "note.saved": "已存入记录区笔记",
1982
+ "review.rate.title": "复习自评——这课现在记得多牢?",
1983
+ "review.again": "再来一次",
1984
+ "review.remembered": "记住了",
1985
+ "review.mastered": "完全掌握",
1986
+ "review.done.good": "已安排下次复习",
1987
+ "review.done.again": "明天再来一遍",
1988
+ "review.jump": "点开这一课去复习",
1989
+ "review.nudge": "有 {n} 课到了复习时间,先回来练一遍?",
1990
+ "review.nudge.go": "去复习",
1991
+ "rail.search.jump": "打开这一课",
1992
+ "proposal.banner": "《{lesson}》可以提前毕业了",
1993
+ "proposal.accept": "接受",
1994
+ "proposal.decline": "再练练",
1995
+ "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
1996
+ "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
1997
+ "tutor.thinking": "导师思考中…",
1998
+ "header.xp": "经验值 {xp}",
1999
+ "header.streak": "连续学习天数",
2000
+ "header.level": "等级",
2001
+ "map.node.locked": "锁定 — 先完成前面的课时",
2002
+ "map.node.due": "到了复习时间",
2003
+ "map.exam.locked": "考试未解锁 — 本章节全部课时掌握度 ≥50% 后开放",
2004
+ "companion.poke": "戳一戳伴学伙伴",
2005
+ "companion.poked": "伴学伙伴向你眨了眨眼",
2006
+ "companion.form.ember": "小焰",
2007
+ "companion.form.frost": "霜绒",
2008
+ "companion.form.moss": "苔芽",
2009
+ "companion.form.star": "星尘",
2010
+ "companion.form.ink": "墨墨",
2011
+ "settings.companion": "伴学伙伴",
2012
+ "settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
2013
+ "quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
2014
+ "quiz.msg.retry": "再来一组类似的题巩固一下。",
2015
+ "quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
2016
+ "quiz.msg.mark-mastered": "这课我觉得掌握了,帮我确认一下——出个综合题检验,通过了就标记为掌握。",
2017
+ "quiz.msg.next-topic": "进入下一个知识点。",
2018
+ "toast.region": "通知",
2019
+ "toast.close": "关闭",
1473
2020
  "composer.busy": "导师正在回复…",
1474
2021
  "rail.empty.demo": "导入示例课程",
1475
2022
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -1583,8 +2130,8 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1583
2130
  "col.tutor": "Tutor",
1584
2131
  "col.bb": "Blackboard",
1585
2132
  "pane.rail": "Courses",
1586
- "pane.tutor": "Tutor",
1587
- "pane.bb": "Board",
2133
+ "pane.chat": "Chat",
2134
+ "pane.note": "Board",
1588
2135
  "viewtab.teach": "Teach",
1589
2136
  "viewtab.cmap": "Concepts",
1590
2137
  "viewtab.cmap.title": "The lesson's concepts relate",
@@ -1608,6 +2155,71 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1608
2155
  "rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
1609
2156
  "rail.empty.button": "Import",
1610
2157
  "composer.send": "Send",
2158
+ "quiz.card.title": "Practice",
2159
+ "quiz.progress": "{cur}/{total}",
2160
+ "quiz.next": "Next",
2161
+ "quiz.finish": "See score",
2162
+ "quiz.score": "Score: {correct}/{total}",
2163
+ "quiz.right": "Correct",
2164
+ "quiz.wrong": "Wrong",
2165
+ "quiz.youChose": "You chose",
2166
+ "quiz.answer": "Answer",
2167
+ "quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
2168
+ "quiz.action.explain-wrong": "Explain my wrong answers",
2169
+ "quiz.action.retry": "Another set",
2170
+ "quiz.action.go-deeper": "Go deeper",
2171
+ "quiz.action.mark-mastered": "Mark me as mastered",
2172
+ "quiz.action.markMastered.hint": "Asks the tutor to verify with a final question, then marks mastery",
2173
+ "quiz.action.next-topic": "Next topic",
2174
+ "artifact.lines": "Lines {from}-{to}: ",
2175
+ "artifact.expand": "Expand",
2176
+ "artifact.guess.title": "Take a guess",
2177
+ "artifact.guess.pick": "I pick \"{label}\"",
2178
+ "artifact.guess.wait": "Your instinct is noted — the tutor reveals next turn",
2179
+ "artifact.sedimented": "A new artifact settled into the notebook",
2180
+ "zone.artifacts": "AI artifacts",
2181
+ "note.quote.ask": "Ask about this",
2182
+ "note.quote.save": "Add note",
2183
+ "note.quote.template": "Please explain this passage: \"{text}\"",
2184
+ "note.saved": "Saved to the record zone",
2185
+ "review.rate.title": "Review self-check — how well do you remember?",
2186
+ "review.again": "Again",
2187
+ "review.remembered": "Remembered",
2188
+ "review.mastered": "Mastered",
2189
+ "review.done.good": "Next review scheduled",
2190
+ "review.done.again": "Back again tomorrow",
2191
+ "review.jump": "Open this lesson to review",
2192
+ "review.nudge": "{n} lessons are due for review — come practice first?",
2193
+ "review.nudge.go": "Review now",
2194
+ "rail.search.jump": "Open this lesson",
2195
+ "proposal.banner": "{lesson} is ready to graduate early",
2196
+ "proposal.accept": "Accept",
2197
+ "proposal.decline": "Keep practicing",
2198
+ "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
2199
+ "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
2200
+ "tutor.thinking": "The tutor is thinking…",
2201
+ "header.xp": "{xp} XP",
2202
+ "header.streak": "Day streak",
2203
+ "header.level": "Level",
2204
+ "map.node.locked": "Locked — finish the previous lessons first",
2205
+ "map.node.due": "Due for review",
2206
+ "map.exam.locked": "Exam locked — every lesson in this section needs ≥50% mastery",
2207
+ "companion.poke": "Poke the companion",
2208
+ "companion.poked": "The companion winks at you",
2209
+ "companion.form.ember": "Ember",
2210
+ "companion.form.frost": "Frost",
2211
+ "companion.form.moss": "Moss",
2212
+ "companion.form.star": "Stardust",
2213
+ "companion.form.ink": "Ink",
2214
+ "settings.companion": "Companion",
2215
+ "settings.companion.hint": "Choose the companion creature in the study panel corner.",
2216
+ "quiz.msg.explain-wrong": "I got some questions wrong — walk me through why and the right line of thinking.",
2217
+ "quiz.msg.retry": "Give me another similar set to consolidate.",
2218
+ "quiz.msg.go-deeper": "I did well on this set — go deeper into the principles and common confusions.",
2219
+ "quiz.msg.mark-mastered": "I feel I have mastered this lesson — verify me with a final question and mark it mastered if I pass.",
2220
+ "quiz.msg.next-topic": "Move on to the next topic.",
2221
+ "toast.region": "Notifications",
2222
+ "toast.close": "Close",
1611
2223
  "composer.busy": "The tutor is replying…",
1612
2224
  "rail.empty.demo": "Import the demo course",
1613
2225
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -2356,6 +2968,782 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2356
2968
  }
2357
2969
  };
2358
2970
  //#endregion
2971
+ //#region src/client/toast.ts
2972
+ /** Per-severity defaults (upstream Toast.tsx): error 6s, warning 5s, rest 4s. */
2973
+ const SEVERITY_DURATION = {
2974
+ default: 4e3,
2975
+ success: 4e3,
2976
+ info: 4e3,
2977
+ warning: 5e3,
2978
+ error: 6e3
2979
+ };
2980
+ const browserScheduler = (fn, ms) => {
2981
+ const handle = window.setTimeout(fn, ms);
2982
+ return () => {
2983
+ window.clearTimeout(handle);
2984
+ };
2985
+ };
2986
+ var ToastStore = class {
2987
+ items = [];
2988
+ nextId = 0;
2989
+ listeners = /* @__PURE__ */ new Set();
2990
+ cancellers = /* @__PURE__ */ new Map();
2991
+ scheduler;
2992
+ constructor(scheduler = browserScheduler) {
2993
+ this.scheduler = scheduler;
2994
+ }
2995
+ getSnapshot() {
2996
+ return this.items;
2997
+ }
2998
+ subscribe(listener) {
2999
+ this.listeners.add(listener);
3000
+ return () => {
3001
+ this.listeners.delete(listener);
3002
+ };
3003
+ }
3004
+ emit() {
3005
+ for (const listener of this.listeners) listener();
3006
+ }
3007
+ /** Enqueue one toast; its auto-exit is scheduled at its duration. */
3008
+ show(message, opts) {
3009
+ const severity = opts?.severity ?? "default";
3010
+ const id = ++this.nextId;
3011
+ const item = {
3012
+ id,
3013
+ message,
3014
+ severity,
3015
+ duration: opts?.duration ?? SEVERITY_DURATION[severity],
3016
+ action: opts?.action,
3017
+ exiting: false
3018
+ };
3019
+ this.items = [...this.items, item];
3020
+ this.cancellers.set(id, this.scheduler(() => this.startExit(id), item.duration));
3021
+ this.emit();
3022
+ return id;
3023
+ }
3024
+ /** Begin the exit (dismiss click or the duration timer); cancels the timer. */
3025
+ startExit(id) {
3026
+ const cancel = this.cancellers.get(id);
3027
+ if (cancel !== void 0) {
3028
+ cancel();
3029
+ this.cancellers.delete(id);
3030
+ }
3031
+ this.items = this.items.map((t) => t.id === id ? {
3032
+ ...t,
3033
+ exiting: true
3034
+ } : t);
3035
+ this.emit();
3036
+ }
3037
+ /** Actually remove — called by the view when the exit animation ends. */
3038
+ finish(id) {
3039
+ this.items = this.items.filter((t) => t.id !== id);
3040
+ this.cancellers.delete(id);
3041
+ this.emit();
3042
+ }
3043
+ /** Drop everything now (the panel unmounted — nothing outlives the panel). */
3044
+ clear() {
3045
+ for (const cancel of this.cancellers.values()) cancel();
3046
+ this.cancellers.clear();
3047
+ this.items = [];
3048
+ this.emit();
3049
+ }
3050
+ };
3051
+ /** The panel-wide singleton stack; consumers call showStudyToast. */
3052
+ const toastStore = new ToastStore();
3053
+ function showStudyToast(message, opts) {
3054
+ return toastStore.show(message, opts);
3055
+ }
3056
+ /**
3057
+ * The exits after one finished card — never fewer than two (the anti-dead-end
3058
+ * promise). Wrong answers → patch the hole first; perfect + near-mastered →
3059
+ * offer the mastery exit; perfect + already graduated → move on.
3060
+ * Pure port of upstream getPostQuizActions.
3061
+ */
3062
+ function getPostQuizActions(score, mastery) {
3063
+ const { correct, total } = score;
3064
+ const allCorrect = total > 0 && correct === total;
3065
+ const hasWrong = total > 0 && correct < total;
3066
+ const alreadyMastered = mastery !== null && mastery >= .9;
3067
+ const nearMastered = mastery !== null && mastery >= .85;
3068
+ if (hasWrong) return [{
3069
+ id: "explain-wrong",
3070
+ advancesMastery: false
3071
+ }, {
3072
+ id: "retry",
3073
+ advancesMastery: false
3074
+ }];
3075
+ if (allCorrect && alreadyMastered) return [{
3076
+ id: "next-topic",
3077
+ advancesMastery: false
3078
+ }, {
3079
+ id: "go-deeper",
3080
+ advancesMastery: false
3081
+ }];
3082
+ if (allCorrect && nearMastered) return [{
3083
+ id: "mark-mastered",
3084
+ advancesMastery: true
3085
+ }, {
3086
+ id: "go-deeper",
3087
+ advancesMastery: false
3088
+ }];
3089
+ if (allCorrect) return [{
3090
+ id: "go-deeper",
3091
+ advancesMastery: false
3092
+ }, {
3093
+ id: "retry",
3094
+ advancesMastery: false
3095
+ }];
3096
+ return [{
3097
+ id: "explain-wrong",
3098
+ advancesMastery: false
3099
+ }, {
3100
+ id: "retry",
3101
+ advancesMastery: false
3102
+ }];
3103
+ }
3104
+ /** localStorage key (upstream quizProgressKey semantics: per artifact). */
3105
+ function quizProgressKey(lessonId, artifactId) {
3106
+ return `dsh-plugin-lookatstudy:quiz:${lessonId}:${artifactId}`;
3107
+ }
3108
+ function loadQuizProgress(lessonId, artifactId, questionCount, storage = localStorage) {
3109
+ try {
3110
+ const raw = storage.getItem(quizProgressKey(lessonId, artifactId));
3111
+ if (raw === null) return {
3112
+ answers: Array(questionCount).fill(-1),
3113
+ done: false
3114
+ };
3115
+ const parsed = JSON.parse(raw);
3116
+ const answers = Array.isArray(parsed.answers) ? parsed.answers.map((a) => typeof a === "number" ? a : -1) : [];
3117
+ const padded = Array(questionCount).fill(-1);
3118
+ for (let i = 0; i < Math.min(answers.length, questionCount); i += 1) padded[i] = answers[i];
3119
+ return {
3120
+ answers: padded,
3121
+ done: parsed.done === true
3122
+ };
3123
+ } catch {
3124
+ return {
3125
+ answers: Array(questionCount).fill(-1),
3126
+ done: false
3127
+ };
3128
+ }
3129
+ }
3130
+ function saveQuizProgress(lessonId, artifactId, progress, storage = localStorage) {
3131
+ try {
3132
+ storage.setItem(quizProgressKey(lessonId, artifactId), JSON.stringify(progress));
3133
+ } catch {}
3134
+ }
3135
+ /** The score over recorded answers (pure). */
3136
+ function quizScore(data, answers) {
3137
+ let correct = 0;
3138
+ let total = 0;
3139
+ data.questions.forEach((q, i) => {
3140
+ const choice = answers[i];
3141
+ if (choice === void 0 || choice < 0) return;
3142
+ total += 1;
3143
+ if (choice === q.answer) correct += 1;
3144
+ });
3145
+ return {
3146
+ correct,
3147
+ total
3148
+ };
3149
+ }
3150
+ const ACTION_ICONS = {
3151
+ "explain-wrong": IconBookFill16,
3152
+ "retry": IconRefreshOutline16,
3153
+ "go-deeper": IconBoltFill16,
3154
+ "mark-mastered": IconCrownFill16,
3155
+ "next-topic": IconStarFill16
3156
+ };
3157
+ /**
3158
+ * The card: one question at a time, click-to-answer (instant local judge +
3159
+ * explanation reveal — the cursor is explicit, so the reveal is never skipped),
3160
+ * progress persisted per artifact; the score screen is an explicit step, and
3161
+ * on first arrival its completion hook goes to the tutor exactly once.
3162
+ */
3163
+ function QuizCard({ lessonId, artifactId, data, masteryPct, send, onFinished }) {
3164
+ const questions = data.questions;
3165
+ const [progress, setProgress] = (0, react.useState)(() => loadQuizProgress(lessonId, artifactId, questions.length));
3166
+ const [cursor, setCursor] = (0, react.useState)(0);
3167
+ const [showScore, setShowScore] = (0, react.useState)(false);
3168
+ const [hookSent, setHookSent] = (0, react.useState)(false);
3169
+ (0, react.useEffect)(() => {
3170
+ const next = loadQuizProgress(lessonId, artifactId, questions.length);
3171
+ setProgress(next);
3172
+ setShowScore(next.done);
3173
+ const firstUnanswered = next.answers.findIndex((a) => a < 0);
3174
+ setCursor(firstUnanswered === -1 ? questions.length - 1 : firstUnanswered);
3175
+ setHookSent(false);
3176
+ }, [
3177
+ lessonId,
3178
+ artifactId,
3179
+ questions.length
3180
+ ]);
3181
+ const allAnswered = progress.answers.every((a) => a >= 0);
3182
+ const score = quizScore(data, progress.answers);
3183
+ const mastery = masteryPct === null ? null : masteryPct / 100;
3184
+ (0, react.useEffect)(() => {
3185
+ if (!showScore || !allAnswered || hookSent) return;
3186
+ setHookSent(true);
3187
+ onFinished?.(score.correct === score.total);
3188
+ const next = {
3189
+ answers: [...progress.answers],
3190
+ done: true
3191
+ };
3192
+ setProgress(next);
3193
+ saveQuizProgress(lessonId, artifactId, next);
3194
+ send(tr("quiz.hook", {
3195
+ correct: score.correct,
3196
+ total: score.total
3197
+ }));
3198
+ }, [
3199
+ showScore,
3200
+ allAnswered,
3201
+ hookSent,
3202
+ lessonId,
3203
+ artifactId,
3204
+ progress.answers,
3205
+ score.correct,
3206
+ score.total,
3207
+ send
3208
+ ]);
3209
+ const answer = (choice) => {
3210
+ const next = {
3211
+ answers: [...progress.answers],
3212
+ done: progress.done
3213
+ };
3214
+ next.answers[cursor] = choice;
3215
+ setProgress(next);
3216
+ saveQuizProgress(lessonId, artifactId, next);
3217
+ };
3218
+ if (showScore && allAnswered) {
3219
+ const actions = getPostQuizActions(score, mastery);
3220
+ return (0, react.createElement)("div", {
3221
+ className: "lks-qcard",
3222
+ "data-lks-quiz": artifactId
3223
+ }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconStarFill16, { size: 14 }), tr("quiz.score", {
3224
+ correct: score.correct,
3225
+ total: score.total
3226
+ })), (0, react.createElement)("div", { className: "lks-qcard-review" }, ...questions.map((q, i) => {
3227
+ const choice = progress.answers[i] ?? -1;
3228
+ const right = choice === q.answer;
3229
+ return (0, react.createElement)("div", {
3230
+ key: i,
3231
+ className: `lks-qcard-q review${right ? "" : " wrong"}`
3232
+ }, (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));
3233
+ })), (0, react.createElement)("div", { className: "lks-qcard-actions" }, ...actions.map((a) => (0, react.createElement)("button", {
3234
+ key: a.id,
3235
+ className: "lks-qcard-action",
3236
+ title: a.advancesMastery ? tr("quiz.action.markMastered.hint") : void 0,
3237
+ onClick: () => {
3238
+ send(tr(`quiz.msg.${a.id}`));
3239
+ }
3240
+ }, (0, react.createElement)(ACTION_ICONS[a.id], { size: 13 }), tr(`quiz.action.${a.id}`)))));
3241
+ }
3242
+ const q = questions[cursor];
3243
+ const chosen = progress.answers[cursor] ?? -1;
3244
+ const options = q.options.map((opt, i) => {
3245
+ const picked = chosen === i;
3246
+ const isRight = i === q.answer;
3247
+ return (0, react.createElement)("button", {
3248
+ key: i,
3249
+ className: `lks-qcard-opt${chosen < 0 ? "" : picked ? isRight ? " right" : " wrong" : isRight ? " right dim" : ""}`,
3250
+ disabled: chosen >= 0,
3251
+ onClick: () => {
3252
+ answer(i);
3253
+ }
3254
+ }, opt);
3255
+ });
3256
+ 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", {
3257
+ className: "lks-qcard-next",
3258
+ onClick: () => {
3259
+ if (cursor + 1 < questions.length) setCursor(cursor + 1);
3260
+ else setShowScore(true);
3261
+ }
3262
+ }, cursor + 1 < questions.length ? tr("quiz.next") : tr("quiz.finish"))));
3263
+ return (0, react.createElement)("div", {
3264
+ className: "lks-qcard",
3265
+ "data-lks-quiz": artifactId
3266
+ }, (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", {
3267
+ cur: cursor + 1,
3268
+ total: questions.length
3269
+ }))), (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));
3270
+ }
3271
+ //#endregion
3272
+ //#region src/client/artifact-cards.tsx
3273
+ /**
3274
+ * The artifact cards (0.15.0 P1b, upstream artifacts port): compare tables,
3275
+ * code walkthroughs, mermaid diagrams (with the expand modal), and the
3276
+ * opening guess — plus the seen-tracking that feeds the notebook badge and
3277
+ * the "new artifact" toast. Rendering is lazy where it is heavy (mermaid via
3278
+ * the enhance pipeline); everything else is plain dsw-token DOM.
3279
+ * @module dsh-plugin-lookatstudy/client/artifact-cards
3280
+ */
3281
+ /** localStorage key for the artifact ids the learner has already seen. */
3282
+ function seenArtifactsKey(lessonId) {
3283
+ return `dsh-plugin-lookatstudy:seen-artifacts:${lessonId}`;
3284
+ }
3285
+ /** Which artifact ids are new (not yet seen); pure over the stored set. */
3286
+ function unseenArtifacts(lessonId, artifacts, storage = localStorage) {
3287
+ let seen;
3288
+ try {
3289
+ const raw = storage.getItem(seenArtifactsKey(lessonId));
3290
+ seen = new Set(Array.isArray(JSON.parse(raw ?? "[]")) ? JSON.parse(raw ?? "[]") : []);
3291
+ } catch {
3292
+ seen = /* @__PURE__ */ new Set();
3293
+ }
3294
+ return artifacts.filter((a) => !seen.has(a.id)).map((a) => a.id);
3295
+ }
3296
+ /** Mark artifact ids seen (opening the notebook counts as seeing them). */
3297
+ function markArtifactsSeen(lessonId, ids, storage = localStorage) {
3298
+ try {
3299
+ let seen = [];
3300
+ try {
3301
+ const parsed = JSON.parse(storage.getItem(seenArtifactsKey(lessonId)) ?? "[]");
3302
+ if (Array.isArray(parsed)) seen = parsed.filter((x) => typeof x === "string");
3303
+ } catch {}
3304
+ storage.setItem(seenArtifactsKey(lessonId), JSON.stringify([.../* @__PURE__ */ new Set([...seen, ...ids])]));
3305
+ } catch {}
3306
+ }
3307
+ /** The cards render mermaid through the shared CDN pipeline
3308
+ * (enhanceDiagrams consumes pre > code.lang-mermaid blocks). */
3309
+ async function renderMermaidInto(el, code, onError) {
3310
+ try {
3311
+ const { enhanceDiagrams } = await Promise.resolve().then(() => enhance_exports);
3312
+ el.textContent = "";
3313
+ const pre = document.createElement("pre");
3314
+ const codeEl = document.createElement("code");
3315
+ codeEl.className = "lang-mermaid";
3316
+ codeEl.textContent = code;
3317
+ pre.appendChild(codeEl);
3318
+ el.appendChild(pre);
3319
+ if (await enhanceDiagrams(el) === 0) onError();
3320
+ } catch {
3321
+ onError();
3322
+ }
3323
+ }
3324
+ /** Compare table card (headers + rows). */
3325
+ function CompareTableCard({ artifact }) {
3326
+ const headers = artifact.data.headers ?? [];
3327
+ const rows = artifact.data.rows ?? [];
3328
+ return (0, react.createElement)("div", {
3329
+ className: "lks-acard",
3330
+ "data-lks-artifact": artifact.id
3331
+ }, (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)))))));
3332
+ }
3333
+ /** Code walkthrough card: line-numbered code + per-segment notes. */
3334
+ function CodeWalkthroughCard({ artifact }) {
3335
+ const language = typeof artifact.data.language === "string" ? artifact.data.language : "text";
3336
+ const code = typeof artifact.data.code === "string" ? artifact.data.code : "";
3337
+ const annotations = artifact.data.annotations ?? [];
3338
+ const lines = code.split("\n");
3339
+ return (0, react.createElement)("div", {
3340
+ className: "lks-acard",
3341
+ "data-lks-artifact": artifact.id
3342
+ }, (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", {
3343
+ key: i,
3344
+ className: "lks-acard-line"
3345
+ }, (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", {
3346
+ key: i,
3347
+ className: "lks-acard-note"
3348
+ }, (0, react.createElement)("b", null, `${tr("artifact.lines", {
3349
+ from: a.lineStart,
3350
+ to: a.lineEnd
3351
+ })}`), a.note))));
3352
+ }
3353
+ /** Mermaid diagram card with an expand modal (upstream DiagramViewerModal). */
3354
+ function DiagramCard({ artifact }) {
3355
+ const mermaid = typeof artifact.data.mermaid === "string" ? artifact.data.mermaid : "";
3356
+ const bodyRef = (0, react.useRef)(null);
3357
+ const [failed, setFailed] = (0, react.useState)(false);
3358
+ const [expanded, setExpanded] = (0, react.useState)(false);
3359
+ (0, react.useEffect)(() => {
3360
+ if (bodyRef.current === null) return;
3361
+ renderMermaidInto(bodyRef.current, mermaid, () => {
3362
+ setFailed(true);
3363
+ });
3364
+ }, [mermaid]);
3365
+ const body = failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", {
3366
+ className: "lks-acard-diagram",
3367
+ ref: bodyRef
3368
+ });
3369
+ return (0, react.createElement)("div", {
3370
+ className: "lks-acard",
3371
+ "data-lks-artifact": artifact.id
3372
+ }, (0, react.createElement)("div", { className: "lks-acard-head" }, artifact.title, (0, react.createElement)("button", {
3373
+ className: "lks-acard-expand",
3374
+ title: tr("artifact.expand"),
3375
+ onClick: () => {
3376
+ setExpanded(true);
3377
+ }
3378
+ }, "⤢")), body, expanded ? (0, react.createElement)("div", {
3379
+ className: "lks-acard-modal",
3380
+ onClick: () => {
3381
+ setExpanded(false);
3382
+ }
3383
+ }, (0, react.createElement)("div", {
3384
+ className: "lks-acard-modal-body",
3385
+ onClick: (e) => {
3386
+ e.stopPropagation();
3387
+ }
3388
+ }, (0, react.createElement)("div", { className: "lks-acard-modal-title" }, artifact.title, (0, react.createElement)("button", {
3389
+ className: "lks-acard-expand",
3390
+ onClick: () => {
3391
+ setExpanded(false);
3392
+ }
3393
+ }, "×")), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", { ref: (el) => {
3394
+ if (el !== null) renderMermaidInto(el, mermaid, () => {
3395
+ setFailed(true);
3396
+ });
3397
+ } }))) : null);
3398
+ }
3399
+ /** The opening guess: two big options, click = pick (sent to the tutor, who
3400
+ * reveals next turn); unscored — no right/wrong language anywhere. */
3401
+ function GuessCard({ artifact, send }) {
3402
+ const prompt = typeof artifact.data.prompt === "string" ? artifact.data.prompt : "";
3403
+ const options = artifact.data.options ?? [];
3404
+ const [picked, setPicked] = (0, react.useState)(null);
3405
+ (0, react.useEffect)(() => {
3406
+ setPicked(null);
3407
+ }, [artifact.id]);
3408
+ return (0, react.createElement)("div", {
3409
+ className: "lks-acard lks-acard-guess",
3410
+ "data-lks-artifact": artifact.id
3411
+ }, (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", {
3412
+ key: opt.id,
3413
+ className: `lks-acard-guess-opt${picked === opt.id ? " picked" : ""}`,
3414
+ disabled: picked !== null,
3415
+ onClick: () => {
3416
+ setPicked(opt.id);
3417
+ send(tr("artifact.guess.pick", { label: opt.label }));
3418
+ }
3419
+ }, opt.label))), picked !== null ? (0, react.createElement)("div", { className: "lks-acard-guess-wait" }, tr("artifact.guess.wait")) : null);
3420
+ }
3421
+ /** Dispatch one artifact row to its card. */
3422
+ function ArtifactCard({ artifact, send }) {
3423
+ switch (artifact.artifactType) {
3424
+ case "compare_table": return (0, react.createElement)(CompareTableCard, { artifact });
3425
+ case "code_walkthrough": return (0, react.createElement)(CodeWalkthroughCard, { artifact });
3426
+ case "diagram": return (0, react.createElement)(DiagramCard, { artifact });
3427
+ case "guess": return (0, react.createElement)(GuessCard, {
3428
+ artifact,
3429
+ send
3430
+ });
3431
+ default: return null;
3432
+ }
3433
+ }
3434
+ //#endregion
3435
+ //#region src/client/companion.tsx
3436
+ /**
3437
+ * The companion creature — the upstream 伴学生物's minimal honest DOM port
3438
+ * (0.15.0 P7): a small being in the panel's bottom-right corner with five
3439
+ * forms, a pure mood machine (idle / talking / celebrating / encouraging),
3440
+ * and the a11y iron rule carried over — reduced-motion renders it static.
3441
+ * Upstream's Electron-native life (mic envelopes, typing squeeze, drag-throw)
3442
+ * has no web-panel equivalent and stays unported by design.
3443
+ * @module dsh-plugin-lookatstudy/client/companion
3444
+ */
3445
+ const COMPANION_FORMS = [
3446
+ {
3447
+ id: "ember",
3448
+ labelKey: "companion.form.ember",
3449
+ body: "#f59e0b",
3450
+ accent: "#dc2626"
3451
+ },
3452
+ {
3453
+ id: "frost",
3454
+ labelKey: "companion.form.frost",
3455
+ body: "#7dd3fc",
3456
+ accent: "#0284c7"
3457
+ },
3458
+ {
3459
+ id: "moss",
3460
+ labelKey: "companion.form.moss",
3461
+ body: "#86efac",
3462
+ accent: "#16a34a"
3463
+ },
3464
+ {
3465
+ id: "star",
3466
+ labelKey: "companion.form.star",
3467
+ body: "#c4b5fd",
3468
+ accent: "#7c3aed"
3469
+ },
3470
+ {
3471
+ id: "ink",
3472
+ labelKey: "companion.form.ink",
3473
+ body: "#cbd5e1",
3474
+ accent: "#334155"
3475
+ }
3476
+ ];
3477
+ /** Normalize a stored form id; junk falls back to 小焰 (upstream's default). */
3478
+ function normalizeCompanionForm(stored) {
3479
+ return COMPANION_FORMS.some((f) => f.id === stored) ? stored : "ember";
3480
+ }
3481
+ function storedCompanionForm() {
3482
+ try {
3483
+ return normalizeCompanionForm(localStorage.getItem("dsh-plugin-lookatstudy:companion-form"));
3484
+ } catch {
3485
+ return "ember";
3486
+ }
3487
+ }
3488
+ function storeCompanionForm(id) {
3489
+ try {
3490
+ localStorage.setItem("dsh-plugin-lookatstudy:companion-form", id);
3491
+ } catch {}
3492
+ }
3493
+ /**
3494
+ * The mood machine — pure. `celebrate` and `encourage` are momentary (the
3495
+ * view decays them back through `decay`); `talking` latches while read-aloud
3496
+ * runs; poke is a self-echoing celebration.
3497
+ */
3498
+ function nextCompanionMood(current, event) {
3499
+ switch (event) {
3500
+ case "talk-start": return "talking";
3501
+ case "talk-end": return current === "talking" ? "idle" : current;
3502
+ case "celebrate":
3503
+ case "poke": return "celebrating";
3504
+ case "encourage": return "encouraging";
3505
+ case "decay": return current === "celebrating" || current === "encouraging" ? "idle" : current;
3506
+ default: return current;
3507
+ }
3508
+ }
3509
+ /** One creature face — the shared skin every form wears. */
3510
+ function CompanionFace({ form, mood }) {
3511
+ const skin = COMPANION_FORMS.find((f) => f.id === form) ?? COMPANION_FORMS[0];
3512
+ 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";
3513
+ 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";
3514
+ return (0, react.createElement)("svg", {
3515
+ width: 64,
3516
+ height: 64,
3517
+ viewBox: "0 0 64 64",
3518
+ "aria-hidden": "true"
3519
+ }, (0, react.createElement)("g", null, (0, react.createElement)("circle", {
3520
+ cx: 32,
3521
+ cy: 34,
3522
+ r: 20,
3523
+ fill: skin.body,
3524
+ stroke: skin.accent,
3525
+ strokeWidth: 2
3526
+ }), form === "ember" ? (0, react.createElement)("path", {
3527
+ 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",
3528
+ fill: skin.accent,
3529
+ opacity: .85
3530
+ }) : form === "star" ? (0, react.createElement)("path", {
3531
+ 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",
3532
+ fill: skin.accent,
3533
+ opacity: .85
3534
+ }) : form === "moss" ? (0, react.createElement)("path", {
3535
+ d: "M32 2 q-2 6 2 10 q-8 0 -8 6 H40 q0 -6 -8 -6 q4 -4 0 -10z",
3536
+ fill: skin.accent,
3537
+ opacity: .85
3538
+ }) : form === "ink" ? (0, react.createElement)("path", {
3539
+ d: "M32 2 q8 12 8 18 a8 8 0 1 1 -16 0 q0 -6 8 -18z",
3540
+ fill: skin.accent,
3541
+ opacity: .55
3542
+ }) : (0, react.createElement)("path", {
3543
+ 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",
3544
+ fill: skin.accent,
3545
+ opacity: .7
3546
+ }), (0, react.createElement)("path", {
3547
+ d: eye,
3548
+ transform: "translate(25 30)",
3549
+ stroke: skin.accent,
3550
+ strokeWidth: 1.6,
3551
+ fill: mood === "celebrating" ? "none" : skin.accent,
3552
+ strokeLinecap: "round"
3553
+ }), (0, react.createElement)("path", {
3554
+ d: eye,
3555
+ transform: "translate(14 30)",
3556
+ stroke: skin.accent,
3557
+ strokeWidth: 1.6,
3558
+ fill: mood === "celebrating" ? "none" : skin.accent,
3559
+ strokeLinecap: "round"
3560
+ }), (0, react.createElement)("path", {
3561
+ d: mouth,
3562
+ stroke: skin.accent,
3563
+ strokeWidth: 1.8,
3564
+ fill: "none",
3565
+ strokeLinecap: "round"
3566
+ })));
3567
+ }
3568
+ /** The creature: fixed bottom-right inside the panel, mood-animated, pokeable. */
3569
+ function Companion({ mood, onPoke }) {
3570
+ const form = storedCompanionForm();
3571
+ return (0, react.createElement)("div", {
3572
+ className: `lks-companion mood-${mood}`,
3573
+ "data-lks-companion": form,
3574
+ title: tr("companion.poke"),
3575
+ role: "button",
3576
+ tabIndex: 0,
3577
+ "aria-label": tr("companion.poke"),
3578
+ onClick: () => {
3579
+ onPoke?.();
3580
+ },
3581
+ onKeyDown: (e) => {
3582
+ if (e.key === "Enter" || e.key === " ") {
3583
+ e.preventDefault();
3584
+ onPoke?.();
3585
+ }
3586
+ }
3587
+ }, (0, react.createElement)(CompanionFace, {
3588
+ form,
3589
+ mood
3590
+ }));
3591
+ }
3592
+ /** The hook driving one creature instance: event → mood, momentary decay. */
3593
+ function useCompanionMood() {
3594
+ const [mood, setMood] = (0, react.useState)("idle");
3595
+ const moodRef = (0, react.useRef)(mood);
3596
+ moodRef.current = mood;
3597
+ const emit = (event) => {
3598
+ const next = nextCompanionMood(moodRef.current, event);
3599
+ if (next !== moodRef.current) {
3600
+ moodRef.current = next;
3601
+ setMood(next);
3602
+ }
3603
+ };
3604
+ (0, react.useEffect)(() => {
3605
+ if (mood !== "celebrating" && mood !== "encouraging") return;
3606
+ const timer = setTimeout(() => {
3607
+ moodRef.current = nextCompanionMood(moodRef.current, "decay");
3608
+ setMood(moodRef.current);
3609
+ }, 2400);
3610
+ return () => {
3611
+ clearTimeout(timer);
3612
+ };
3613
+ }, [mood]);
3614
+ return {
3615
+ mood,
3616
+ emit
3617
+ };
3618
+ }
3619
+ //#endregion
3620
+ //#region src/client/highlights.ts
3621
+ /**
3622
+ * Locate a selection inside the model text: exact `selected` text search,
3623
+ * disambiguated by a fuzzy `surrounding` fingerprint (the same sentence
3624
+ * appearing twice resolves to the occurrence whose context matches).
3625
+ * @returns the global [start, end) in model.text, or null when absent.
3626
+ */
3627
+ function locateInModel(model, selected, surrounding) {
3628
+ const needle = selected.trim();
3629
+ if (needle.length < 2) return null;
3630
+ const hits = [];
3631
+ let from = 0;
3632
+ for (;;) {
3633
+ const hit = model.text.indexOf(needle, from);
3634
+ if (hit === -1) break;
3635
+ hits.push(hit);
3636
+ from = hit + 1;
3637
+ }
3638
+ if (hits.length === 0) return null;
3639
+ if (hits.length === 1 || surrounding === void 0 || surrounding.trim() === "") return {
3640
+ start: hits[0],
3641
+ end: hits[0] + needle.length
3642
+ };
3643
+ const finger = surrounding.replace(/\s+/g, "");
3644
+ let best = hits[0];
3645
+ let bestScore = -1;
3646
+ for (const hit of hits) {
3647
+ const context = model.text.slice(Math.max(0, hit - 30), hit + needle.length + 30).replace(/\s+/g, "");
3648
+ let score = 0;
3649
+ for (let i = 0; i < finger.length; i += 4) if (context.includes(finger.slice(i, i + 4))) score += 1;
3650
+ if (score > bestScore) {
3651
+ bestScore = score;
3652
+ best = hit;
3653
+ }
3654
+ }
3655
+ return {
3656
+ start: best,
3657
+ end: best + needle.length
3658
+ };
3659
+ }
3660
+ /** Plan the per-node segments covering global [start, end) — pure. */
3661
+ function planSegments(nodes, start, end) {
3662
+ const segments = [];
3663
+ nodes.forEach((entry, index) => {
3664
+ if (entry.end <= start || entry.start >= end) return;
3665
+ segments.push({
3666
+ index,
3667
+ localStart: Math.max(0, start - entry.start),
3668
+ localEnd: Math.min(entry.end - entry.start, end - entry.start)
3669
+ });
3670
+ });
3671
+ return segments;
3672
+ }
3673
+ /** Build the DOM text model for the prose container (the walker rules mirror
3674
+ * upstream: skip script/style and KaTeX glyph layers; skip existing marks so
3675
+ * save-time and apply-time models stay symmetric). */
3676
+ function getTextModel(container, opts) {
3677
+ const nodes = [];
3678
+ let text = "";
3679
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode(node) {
3680
+ const parent = node.parentElement;
3681
+ if (parent === null) return NodeFilter.FILTER_REJECT;
3682
+ const tag = parent.tagName;
3683
+ if (tag === "SCRIPT" || tag === "STYLE") return NodeFilter.FILTER_REJECT;
3684
+ if (parent.closest(".katex-html")) return NodeFilter.FILTER_REJECT;
3685
+ if (parent.closest(".katex-mathml") !== null && parent.closest("annotation") === null) return NodeFilter.FILTER_REJECT;
3686
+ if (opts?.includeMarks !== true && parent.closest("mark.lks-hl") !== null) return NodeFilter.FILTER_REJECT;
3687
+ return NodeFilter.FILTER_ACCEPT;
3688
+ } });
3689
+ let n;
3690
+ while ((n = walker.nextNode()) !== null) {
3691
+ const t = n;
3692
+ const content = t.textContent ?? "";
3693
+ nodes.push({
3694
+ node: t,
3695
+ start: text.length,
3696
+ end: text.length + content.length
3697
+ });
3698
+ text += content;
3699
+ }
3700
+ return {
3701
+ text,
3702
+ nodes
3703
+ };
3704
+ }
3705
+ /** The mark class applied to persisted highlights. */
3706
+ const HIGHLIGHT_MARK = "lks-hl";
3707
+ /**
3708
+ * Apply one highlight range to the live DOM: split the boundary text nodes
3709
+ * and wrap the covering segments in marks. Returns the created marks.
3710
+ */
3711
+ function wrapRange(model, start, end) {
3712
+ const marks = [];
3713
+ for (const seg of planSegments(model.nodes, start, end)) {
3714
+ const node = model.nodes[seg.index].node;
3715
+ if (node.parentElement === null) continue;
3716
+ if (node.parentElement.closest(`mark.lks-hl`) !== null) continue;
3717
+ const mid = seg.localStart > 0 ? node.splitText(seg.localStart) : node;
3718
+ const localEnd = seg.localEnd - seg.localStart;
3719
+ if (mid.textContent !== null && mid.textContent.length > localEnd) mid.splitText(localEnd);
3720
+ const mark = document.createElement("mark");
3721
+ mark.className = HIGHLIGHT_MARK;
3722
+ mid.parentElement.insertBefore(mark, mid);
3723
+ mark.appendChild(mid);
3724
+ marks.push(mark);
3725
+ }
3726
+ return marks;
3727
+ }
3728
+ /**
3729
+ * Render the persisted highlights for one lesson: each record-zone note with a
3730
+ * quote anchors a mark. Idempotent per call — call on a freshly rendered
3731
+ * (pre-mark) container; the model skips existing marks so re-entry is safe.
3732
+ * @returns how many notes got at least one mark.
3733
+ */
3734
+ function applyHighlights(container, quotes) {
3735
+ let applied = 0;
3736
+ for (const quote of quotes) {
3737
+ const trimmed = quote.trim();
3738
+ if (trimmed.length < 2) continue;
3739
+ const model = getTextModel(container);
3740
+ const located = locateInModel(model, trimmed, void 0);
3741
+ if (located === null) continue;
3742
+ if (wrapRange(model, located.start, located.end).length > 0) applied += 1;
3743
+ }
3744
+ return applied;
3745
+ }
3746
+ //#endregion
2359
3747
  //#region src/client/views.tsx
2360
3748
  /**
2361
3749
  * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
@@ -2423,6 +3811,56 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2423
3811
  return runs.at(-1) ?? [];
2424
3812
  }
2425
3813
  /**
3814
+ * Merge the rail's two search tracks into the results panel: title hits
3815
+ * (lessons whose name matches — they are also filtered into the tree) first,
3816
+ * then full-text hits not already covered, snippets carried for the body
3817
+ * matches. Pure; empty queries yield no panel.
3818
+ */
3819
+ function mergeRailSearch(query, lessons, textHits) {
3820
+ const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
3821
+ if (keys.length === 0) return [];
3822
+ const rows = [];
3823
+ const seen = /* @__PURE__ */ new Set();
3824
+ const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
3825
+ for (const lesson of lessons) {
3826
+ if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
3827
+ rows.push({
3828
+ lessonId: lesson.id,
3829
+ title: lesson.title,
3830
+ snippet: "",
3831
+ courseTitle: "",
3832
+ fromTitle: true
3833
+ });
3834
+ seen.add(lesson.id);
3835
+ }
3836
+ for (const hit of textHits) {
3837
+ if (seen.has(hit.lessonId)) continue;
3838
+ rows.push({
3839
+ lessonId: hit.lessonId,
3840
+ title: hit.lessonTitle,
3841
+ snippet: hit.snippet,
3842
+ courseTitle: hit.courseTitle,
3843
+ fromTitle: false
3844
+ });
3845
+ seen.add(hit.lessonId);
3846
+ }
3847
+ return rows.slice(0, 12);
3848
+ }
3849
+ /**
3850
+ * A section's effective openness: the user's explicit toggle (persisted per
3851
+ * course) overrides the frontier-based default. Pure.
3852
+ */
3853
+ function effectiveOpen(sectionTitle, defaultOpen, overrides) {
3854
+ return overrides[sectionTitle] ?? defaultOpen;
3855
+ }
3856
+ /**
3857
+ * The narrow-mode pane picker: only valid ids, remembering the last choice
3858
+ * (upstream's narrow switcher semantics). Pure.
3859
+ */
3860
+ function pickNarrowPane(stored) {
3861
+ return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
3862
+ }
3863
+ /**
2426
3864
  * Default expansion for one rail section: collapsed when every study lesson is
2427
3865
  * done (mastered) or not yet reachable (locked). The focus lesson's section
2428
3866
  * stays open; exam nodes never force a section open. Pure.
@@ -2431,6 +3869,248 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2431
3869
  return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
2432
3870
  }
2433
3871
  //#endregion
3872
+ //#region src/vendor/map-layout.ts
3873
+ /** 容器宽度(章节内可用宽度,扣除 padding)。MapRail 章节容器 px-2。 */
3874
+ const CONTAINER_WIDTH = 268;
3875
+ const NODE_RADIUS = 28;
3876
+ const NODE_SPACING_Y = 104;
3877
+ const BALLOON_MARGIN = 18;
3878
+ /** 首节点中心下限(防顶撞上方章节路牌)。MapRail 渲染偏移 +12 + NODE_H/2=38 → wrapper 顶 = y-26 ≥ 8。 */
3879
+ const TOP_PAD = 34;
3880
+ /** y 轴抖动幅度(px)。贪心兜底后,jitter 不再是重叠源,仅负责高低参差。 */
3881
+ const Y_JITTER = 26;
3882
+ /** 绳子下垂量(px,模拟重力)。让贝塞尔像被拽下来的绳子,而非 S 形箭头。 */
3883
+ const ROPE_SAG = 22;
3884
+ /**
3885
+ * 确定性字符串哈希(FNV-1a 变体,32-bit)。
3886
+ * 同输入永远同输出 → 同一节点 id 每次渲染算出的抖动相同 → 气球位置稳定不跳。
3887
+ * 用于气球横向抖动 + bob 相位 + 星点位置 + 天体色切。
3888
+ */
3889
+ function hashStr(s) {
3890
+ let h = 2166136261;
3891
+ for (let i = 0; i < s.length; i++) {
3892
+ h ^= s.charCodeAt(i);
3893
+ h = Math.imul(h, 16777619);
3894
+ }
3895
+ h ^= h >>> 16;
3896
+ h = Math.imul(h, 2246822507);
3897
+ h ^= h >>> 13;
3898
+ h = Math.imul(h, 3266489909);
3899
+ h ^= h >>> 16;
3900
+ return h >>> 0;
3901
+ }
3902
+ /**
3903
+ * 算某 section 的气球节点坐标 + 绳子连接路径。
3904
+ * 容器高度由 nodes 数量决定(调用方据此设容器 min-height)。
3905
+ *
3906
+ * 抖动算法:v0.6 同时在 x 和 y 两轴抖动 → 气球真正"飘"在空中,
3907
+ * 不再是呆板的直线/微弯列。x 大幅抖动(用满容器宽度),y 在基线上下 ±Y_JITTER。
3908
+ * 两个独立哈希(x 用 seed:i,y 用 seed:i:y)→ 抖动方向不相关,分布更自然。
3909
+ *
3910
+ * v0.8 防重叠:抖动后跑一趟**贪心向下推**(确定性,只增 y,保持单调),
3911
+ * 1) 首节点中心不低于 TOP_PAD —— 防顶撞上方章节路牌;
3912
+ * 2) 相邻节点中心纵向距 ≥ MIN_GAP_Y(= 盒高 + 8px 呼吸)—— 视觉盒永不重叠。
3913
+ * 任何 seed / 任何课数都保证不重叠(verify-map-layout T11 跨 240 种子穷举验证)。
3914
+ */
3915
+ function computeBalloonLayout(lessonCount, containerWidth = CONTAINER_WIDTH, seed = "") {
3916
+ const nodes = [];
3917
+ const centerX = containerWidth / 2;
3918
+ const maxDrift = containerWidth / 2 - BALLOON_MARGIN - NODE_RADIUS;
3919
+ for (let i = 0; i < lessonCount; i++) {
3920
+ const baseY = TOP_PAD + i * NODE_SPACING_Y;
3921
+ const x = centerX + (hashStr(`${seed}:${i}`) / 4294967295 * 2 - 1) * maxDrift;
3922
+ const y = baseY + (hashStr(`${seed}:${i}:y`) / 4294967295 * 2 - 1) * Y_JITTER;
3923
+ nodes.push({
3924
+ x,
3925
+ y,
3926
+ index: i
3927
+ });
3928
+ }
3929
+ if (nodes.length > 0) {
3930
+ if (nodes[0].y < TOP_PAD) nodes[0].y = TOP_PAD;
3931
+ for (let i = 1; i < nodes.length; i++) {
3932
+ const minY = nodes[i - 1].y + 84;
3933
+ if (nodes[i].y < minY) nodes[i].y = minY;
3934
+ }
3935
+ }
3936
+ const segments = [];
3937
+ for (let i = 0; i < nodes.length - 1; i++) segments.push({
3938
+ from: nodes[i],
3939
+ to: nodes[i + 1],
3940
+ index: i
3941
+ });
3942
+ return {
3943
+ nodes,
3944
+ segments,
3945
+ height: nodes.length === 0 ? 40 : nodes[nodes.length - 1].y + 38 + 10
3946
+ };
3947
+ }
3948
+ /**
3949
+ * 两节点间的绳子贝塞尔(下垂)。
3950
+ * 控制点:y 中点 + ROPE_SAG(向下垂,模拟重力);x 用各自端点 x。
3951
+ * 比原 S 形软,像被拽下来的绳子。
3952
+ */
3953
+ function balloonSegmentToPath(seg) {
3954
+ const { from, to } = seg;
3955
+ const controlY = (from.y + to.y) / 2 + ROPE_SAG;
3956
+ return `M ${from.x} ${from.y} C ${from.x} ${controlY}, ${to.x} ${controlY}, ${to.x} ${to.y}`;
3957
+ }
3958
+ //#endregion
3959
+ //#region src/client/maprail.tsx
3960
+ /**
3961
+ * MapRail balloon map (P9b) — the upstream v0.28 course map ported onto the
3962
+ * plugin's rail: deterministic balloon layout (vendored mapLayout engine),
3963
+ * sagging bezier ropes with path-draw on walked segments, state bubbles with
3964
+ * 3D sphere lighting, signpost section heads, and the sky backdrop. This is
3965
+ * upstream's own reduced-motion presentation (static layout + balloon-bob
3966
+ * CSS), which is exactly the deterministic half of its physics map.
3967
+ */
3968
+ /** Upstream bubbleClass port: the four study states + the three exam variants.
3969
+ * Status→class goes through a table (in_progress → in-progress, like upstream's switch). */
3970
+ const STATUS_CLASS = {
3971
+ locked: "locked",
3972
+ available: "available",
3973
+ in_progress: "in-progress",
3974
+ mastered: "mastered"
3975
+ };
3976
+ function bubbleClasses(lesson, examAllowed) {
3977
+ if (lesson.kind === "exam") {
3978
+ if (!examAllowed) return "lks-bubble lks-exam-locked";
3979
+ return lesson.status === "mastered" ? "lks-bubble lks-exam-passed" : "lks-bubble lks-exam";
3980
+ }
3981
+ return `lks-bubble lks-bubble-${STATUS_CLASS[lesson.status] ?? "locked"}`;
3982
+ }
3983
+ /** A rope segment is "walked" when its FROM node has been opened
3984
+ * (upstream ropeStyleIsPassed: mastered | in_progress | available). */
3985
+ function ropePassed(from) {
3986
+ return from.status === "mastered" || from.status === "in_progress" || from.status === "available";
3987
+ }
3988
+ /** Deterministic sky preset per course (upstream pickPreset's CSS-essence port). */
3989
+ function pickSky(courseId) {
3990
+ return [
3991
+ "day",
3992
+ "dusk",
3993
+ "night"
3994
+ ][hashStr(courseId) % 3];
3995
+ }
3996
+ /** One section: signpost head (still the plugin's collapse toggle) + the balloon field. */
3997
+ function MapSectionView({ section, examAllowed, open, onToggle, onJump }) {
3998
+ const pathRef = (0, react.useRef)(null);
3999
+ const [containerW, setContainerW] = (0, react.useState)(268);
4000
+ (0, react.useEffect)(() => {
4001
+ if (pathRef.current === null) return;
4002
+ const measure = () => {
4003
+ const w = pathRef.current?.clientWidth ?? 268;
4004
+ setContainerW(w > 0 ? w : 268);
4005
+ };
4006
+ measure();
4007
+ const ro = new ResizeObserver(measure);
4008
+ ro.observe(pathRef.current);
4009
+ return () => {
4010
+ ro.disconnect();
4011
+ };
4012
+ }, [open]);
4013
+ const lessons = section.lessons;
4014
+ const layout = computeBalloonLayout(lessons.length, containerW, section.title);
4015
+ return (0, react.createElement)("section", { className: "lks-mapsec" }, (0, react.createElement)("button", {
4016
+ type: "button",
4017
+ className: "lks-signpost",
4018
+ "aria-expanded": String(open),
4019
+ title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
4020
+ onClick: onToggle
4021
+ }, (0, react.createElement)("span", { className: "lks-signpost-num" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks-signpost-title" }, section.title), (0, react.createElement)("span", { className: "lks-signpost-caret" }, open ? "▾" : "▸")), open && lessons.length > 0 ? (0, react.createElement)("div", {
4022
+ ref: pathRef,
4023
+ className: "lks-mapfield",
4024
+ style: { minHeight: `${String(layout.height)}px` }
4025
+ }, (0, react.createElement)("svg", {
4026
+ className: "lks-mapropes",
4027
+ "aria-hidden": "true",
4028
+ style: { height: `${String(layout.height)}px` }
4029
+ }, ...layout.segments.map((seg) => {
4030
+ const passed = ropePassed(lessons[seg.index] ?? { status: "locked" });
4031
+ return (0, react.createElement)("path", {
4032
+ key: seg.index,
4033
+ d: balloonSegmentToPath(seg),
4034
+ stroke: passed ? "var(--brand)" : "var(--ink-faint)",
4035
+ "stroke-width": passed ? "4" : "2.5",
4036
+ "stroke-opacity": passed ? "0.6" : "0.5",
4037
+ fill: "none",
4038
+ "stroke-linecap": "round",
4039
+ "stroke-dasharray": passed ? "none" : "3 7",
4040
+ style: passed ? {
4041
+ pathLength: 1,
4042
+ animation: "lks-path-draw 600ms var(--ease-out-expo)"
4043
+ } : void 0
4044
+ });
4045
+ })), ...lessons.map((lesson, i) => {
4046
+ const node = layout.nodes[i];
4047
+ if (node === void 0) return null;
4048
+ const h = hashStr(lesson.id);
4049
+ const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
4050
+ const showRing = lesson.kind !== "exam" && (lesson.status === "in_progress" || lesson.status === "mastered");
4051
+ const ringPct = lesson.status === "mastered" ? 100 : lesson.status === "in_progress" ? lesson.masteryPct ?? 0 : 0;
4052
+ return (0, react.createElement)("div", {
4053
+ key: lesson.id,
4054
+ className: "lks-mapnode",
4055
+ style: {
4056
+ left: `${String(node.x - 55)}px`,
4057
+ top: `${String(node.y - 38 + 10)}px`,
4058
+ "--bob-delay": `${String(h % 40 / 10)}s`,
4059
+ "--bob-duration": `${String(5 + h % 30 / 10)}s`
4060
+ }
4061
+ }, (0, react.createElement)("button", {
4062
+ type: "button",
4063
+ className: `${bubbleClasses(lesson, examAllowed)}${lesson.focus ? " selected" : ""}`,
4064
+ "aria-disabled": locked || void 0,
4065
+ title: `${lesson.title} — ${lesson.kind === "exam" && !examAllowed ? tr("map.exam.locked") : locked ? tr("map.node.locked") : lesson.due ? tr("map.node.due") : lesson.title}`,
4066
+ onClick: () => {
4067
+ if (!locked) onJump(lesson.id);
4068
+ }
4069
+ }, showRing ? (0, react.createElement)("svg", {
4070
+ className: "lks-bubble-ring",
4071
+ viewBox: "0 0 56 56",
4072
+ "aria-hidden": "true"
4073
+ }, (0, react.createElement)("circle", {
4074
+ cx: 28,
4075
+ cy: 28,
4076
+ r: 25,
4077
+ fill: "none",
4078
+ stroke: "rgb(255 255 255 / 0.2)",
4079
+ "stroke-width": 2.5
4080
+ }), (0, react.createElement)("circle", {
4081
+ cx: 28,
4082
+ cy: 28,
4083
+ r: 25,
4084
+ fill: "none",
4085
+ stroke: lesson.status === "mastered" ? "#ffc800" : "white",
4086
+ "stroke-width": 2.5,
4087
+ "stroke-linecap": "round",
4088
+ "stroke-dasharray": `${String(ringPct / 100 * 157)} 157`
4089
+ })) : null, lesson.kind === "exam" ? locked ? (0, react.createElement)(IconLockFill16, {
4090
+ size: 22,
4091
+ className: "lks-bubble-glyph dim"
4092
+ }) : (0, react.createElement)(IconGoalOutline16, {
4093
+ size: 24,
4094
+ className: "lks-bubble-glyph"
4095
+ }) : lesson.status === "locked" ? (0, react.createElement)(IconLockFill16, {
4096
+ size: 20,
4097
+ className: "lks-bubble-glyph dim"
4098
+ }) : lesson.status === "mastered" ? (0, react.createElement)(IconCrownFill16, {
4099
+ size: 24,
4100
+ className: "lks-bubble-glyph"
4101
+ }) : lesson.status === "in_progress" ? (0, react.createElement)(IconBookFill16, {
4102
+ size: 24,
4103
+ className: "lks-bubble-glyph"
4104
+ }) : (0, react.createElement)(IconStarFill16, {
4105
+ size: 24,
4106
+ className: "lks-bubble-glyph"
4107
+ }), lesson.due && !locked && lesson.kind !== "exam" ? (0, react.createElement)("span", {
4108
+ className: "lks-bubble-due",
4109
+ "aria-label": tr("map.node.due")
4110
+ }, "!") : null), lesson.focus ? (0, react.createElement)("div", { className: "lks-bubble-name" }, lesson.title) : null);
4111
+ })) : null);
4112
+ }
4113
+ //#endregion
2434
4114
  //#region src/client/panel.tsx
2435
4115
  /**
2436
4116
  * The study panel — the sidebar-entry surface that takes over the center
@@ -2469,12 +4149,25 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2469
4149
  ["record", "zone.record"],
2470
4150
  ["practice", "zone.practice"]
2471
4151
  ];
2472
- function statusIcon(kind, status) {
2473
- if (kind === "exam") return (0, react.createElement)(IconGoalOutline16, { size: 14 });
2474
- if (status === "mastered") return (0, react.createElement)(IconCrownFill16, { size: 14 });
2475
- if (status === "in_progress") return (0, react.createElement)(IconBookFill16, { size: 14 });
2476
- if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
2477
- return (0, react.createElement)(IconLockFill16, { size: 14 });
4152
+ /** Toast severity glyph (P6): the state color rides the icon, text stays ink. */
4153
+ function toastIcon(severity) {
4154
+ if (severity === "success") return (0, react.createElement)(IconStarFill16, {
4155
+ size: 14,
4156
+ className: "lks-toast-glyph ok"
4157
+ });
4158
+ if (severity === "warning") return (0, react.createElement)(IconWarningOutline16, {
4159
+ size: 14,
4160
+ className: "lks-toast-glyph warn"
4161
+ });
4162
+ if (severity === "error") return (0, react.createElement)(IconWarningOutline16, {
4163
+ size: 14,
4164
+ className: "lks-toast-glyph err"
4165
+ });
4166
+ if (severity === "info") return (0, react.createElement)(IconGlobeOutline14, {
4167
+ size: 13,
4168
+ className: "lks-toast-glyph info"
4169
+ });
4170
+ return null;
2478
4171
  }
2479
4172
  function examOpen(lessons) {
2480
4173
  return lessons.every((l) => l.kind !== "study" || (l.masteryPct ?? 0) >= 50);
@@ -2540,10 +4233,30 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2540
4233
  const [rows, setRows] = (0, react.useState)([]);
2541
4234
  const [feedAttached, setFeedAttached] = (0, react.useState)(false);
2542
4235
  const [draft, setDraft] = (0, react.useState)("");
4236
+ const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
2543
4237
  const lesson = data?.lesson ?? null;
2544
4238
  const localBound = (0, react.useRef)(null);
2545
4239
  const streamEl = (0, react.useRef)(null);
2546
4240
  const boundId = localBound.current ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
4241
+ const nudged = (0, react.useRef)(false);
4242
+ (0, react.useEffect)(() => {
4243
+ if (nudged.current || (data?.dueCount ?? 0) === 0) return;
4244
+ nudged.current = true;
4245
+ showStudyToast(tr("review.nudge", { n: data.dueCount }), {
4246
+ severity: "warning",
4247
+ action: {
4248
+ label: tr("review.nudge.go"),
4249
+ onClick: () => {
4250
+ const first = data?.due[0];
4251
+ if (first !== void 0) setFocus(first.lessonId).catch(() => {});
4252
+ }
4253
+ }
4254
+ });
4255
+ }, [
4256
+ data?.dueCount,
4257
+ data?.due,
4258
+ setFocus
4259
+ ]);
2547
4260
  (0, react.useEffect)(() => {
2548
4261
  if (boundId === null) {
2549
4262
  setRows([]);
@@ -2652,14 +4365,29 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2652
4365
  }
2653
4366
  })();
2654
4367
  };
2655
- const body = (0, react.createElement)("div", { className: "lks14-body" }, (0, react.createElement)(CourseRail, {
4368
+ const companion = useCompanionMood();
4369
+ const setCompanionEvent = companion.emit;
4370
+ const progress = data?.progress ?? null;
4371
+ const body = (0, react.createElement)("div", {
4372
+ className: "lks14-body",
4373
+ "data-pane": narrowPane
4374
+ }, (0, react.createElement)(CourseRail, {
2656
4375
  data,
2657
4376
  activate,
2658
4377
  setFocus,
2659
4378
  searchLessons,
2660
4379
  deleteCourse,
2661
4380
  send
2662
- }), (0, react.createElement)(ChatPane, {
4381
+ }), (0, react.createElement)("div", { className: "lks14-righthalf" }, (0, react.createElement)("div", { className: "lks14-appheader" }, (0, react.createElement)("span", { className: "lks-hdr-title" }, lesson?.courseTitle ?? tr("tab.label")), (0, react.createElement)("span", {
4382
+ className: "lks-hdr-xp",
4383
+ title: tr("header.xp", { xp: progress?.totalXp ?? 0 })
4384
+ }, (0, react.createElement)("span", { className: "lks-hdr-stat" }, "⚡"), (0, react.createElement)("span", { className: "lks-hdr-xpbar" }, (0, react.createElement)("i", { style: { width: `${Math.min(100, progress?.levelPct ?? 0)}%` } }))), (0, react.createElement)("span", {
4385
+ className: "lks-hdr-stat lks-hdr-streak",
4386
+ title: tr("header.streak")
4387
+ }, "🔥", String(progress?.streak ?? 0)), (0, react.createElement)("span", {
4388
+ className: "lks-hdr-stat",
4389
+ title: tr("header.level")
4390
+ }, `Lv${String(progress?.level ?? 1)}`)), (0, react.createElement)("div", { className: "lks14-row" }, (0, react.createElement)(ChatPane, {
2663
4391
  data,
2664
4392
  lesson,
2665
4393
  rows,
@@ -2670,15 +4398,78 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2670
4398
  draft,
2671
4399
  setDraft,
2672
4400
  send,
2673
- setMode
4401
+ setMode,
4402
+ narrowPane,
4403
+ companionEvent: setCompanionEvent
2674
4404
  }), (0, react.createElement)(NotebookPane, {
2675
4405
  data,
2676
- deleteNote
2677
- }));
4406
+ deleteNote,
4407
+ send,
4408
+ companionEvent: setCompanionEvent
4409
+ }))));
2678
4410
  return (0, react.createElement)("div", {
2679
- className: "lks14",
4411
+ className: "lks14 lks-ui",
2680
4412
  "data-lks-panel": ""
2681
- }, body);
4413
+ }, (0, react.createElement)(Companion, {
4414
+ mood: companion.mood,
4415
+ onPoke: () => {
4416
+ setCompanionEvent("poke");
4417
+ showStudyToast(tr("companion.poked"), { severity: "success" });
4418
+ }
4419
+ }), (0, react.createElement)("div", { className: "lks14-switch" }, ...[
4420
+ "rail",
4421
+ "chat",
4422
+ "note"
4423
+ ].map((pane) => (0, react.createElement)("button", {
4424
+ key: pane,
4425
+ className: `lks14-switch-btn${narrowPane === pane ? " on" : ""}`,
4426
+ "aria-pressed": String(narrowPane === pane),
4427
+ onClick: () => {
4428
+ setNarrowPane(pane);
4429
+ }
4430
+ }, tr(`pane.${pane}`)))), body, (0, react.createElement)(StudyToastStack));
4431
+ }
4432
+ /** P6: the panel's toast stack (upstream Toast port) — severity capsules,
4433
+ * top-center, auto-dismiss with an exit-animation handshake; the store clears
4434
+ * on unmount so no toast outlives the panel. */
4435
+ function StudyToastStack() {
4436
+ const [items, setItems] = (0, react.useState)([]);
4437
+ (0, react.useEffect)(() => {
4438
+ const update = () => {
4439
+ setItems([...toastStore.getSnapshot()]);
4440
+ };
4441
+ update();
4442
+ const unsubscribe = toastStore.subscribe(update);
4443
+ return () => {
4444
+ unsubscribe();
4445
+ toastStore.clear();
4446
+ };
4447
+ }, []);
4448
+ return (0, react.createElement)("div", {
4449
+ className: "lks-toasts",
4450
+ role: "region",
4451
+ "aria-live": "polite",
4452
+ "aria-label": tr("toast.region")
4453
+ }, ...items.map((t) => (0, react.createElement)("div", {
4454
+ key: t.id,
4455
+ className: `lks-toast${t.exiting ? " exiting" : ""}`,
4456
+ "data-severity": t.severity,
4457
+ onAnimationEnd: t.exiting ? () => {
4458
+ toastStore.finish(t.id);
4459
+ } : void 0
4460
+ }, toastIcon(t.severity), (0, react.createElement)("span", { className: "lks-toast-text" }, t.message), t.action === void 0 ? null : (0, react.createElement)("button", {
4461
+ className: "lks-toast-action",
4462
+ onClick: () => {
4463
+ t.action?.onClick?.();
4464
+ toastStore.startExit(t.id);
4465
+ }
4466
+ }, t.action.label), (0, react.createElement)("button", {
4467
+ className: "lks-toast-close",
4468
+ "aria-label": tr("toast.close"),
4469
+ onClick: () => {
4470
+ toastStore.startExit(t.id);
4471
+ }
4472
+ }, "×"))));
2682
4473
  }
2683
4474
  /** Module-level shell handle so the panel can suppress hand-back on internal opens. */
2684
4475
  const PANEL_SHELL = { current: null };
@@ -2689,6 +4480,14 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2689
4480
  function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send }) {
2690
4481
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
2691
4482
  const [query, setQuery] = (0, react.useState)("");
4483
+ const [searchRows, setSearchRows] = (0, react.useState)([]);
4484
+ const [sectionOverrides, setSectionOverrides] = (0, react.useState)({});
4485
+ const toggleSection = (title, next) => {
4486
+ setSectionOverrides((cur) => ({
4487
+ ...cur,
4488
+ [title]: next
4489
+ }));
4490
+ };
2692
4491
  const [confirmDelete, setConfirmDelete] = (0, react.useState)(false);
2693
4492
  const [showImport, setShowImport] = (0, react.useState)(false);
2694
4493
  const [error, setError] = (0, react.useState)(null);
@@ -2710,6 +4509,36 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2710
4509
  window.removeEventListener("keydown", onKey);
2711
4510
  };
2712
4511
  }, [confirmDelete]);
4512
+ (0, react.useEffect)(() => {
4513
+ if (query.trim().length < 2 || data === null) {
4514
+ setSearchRows([]);
4515
+ return;
4516
+ }
4517
+ let cancelled = false;
4518
+ const timer = setTimeout(() => {
4519
+ searchLessons(query).then((textHits) => {
4520
+ if (cancelled) return;
4521
+ 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);
4522
+ setSearchRows(mergeRailSearch(query, lessons.map((l) => ({
4523
+ id: l.id,
4524
+ title: l.title,
4525
+ status: l.status,
4526
+ kind: l.kind
4527
+ })), textHits));
4528
+ }).catch(() => {
4529
+ if (!cancelled) setSearchRows([]);
4530
+ });
4531
+ }, 250);
4532
+ return () => {
4533
+ cancelled = true;
4534
+ clearTimeout(timer);
4535
+ };
4536
+ }, [
4537
+ query,
4538
+ selectedCourse,
4539
+ data,
4540
+ searchLessons
4541
+ ]);
2713
4542
  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", {
2714
4543
  className: "lks-btn primary",
2715
4544
  style: { margin: "10px 0" },
@@ -2719,7 +4548,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2719
4548
  }, (0, react.createElement)(IconDownloadOutline16, null), tr("rail.empty.demo")), (0, react.createElement)(ImportRow, { send })) : (() => {
2720
4549
  const courseId = data.courses.some((c) => c.courseId === selectedCourse) ? selectedCourse : data.courses[0].courseId;
2721
4550
  const course = data.courses.find((c) => c.courseId === courseId);
2722
- return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks14-railhead" }, data.courses.length > 1 ? (0, react.createElement)("select", {
4551
+ return (0, react.createElement)("div", { className: `lks-map lks-sky-${pickSky(courseId)}` }, (0, react.createElement)("div", { className: "lks14-railhead" }, data.courses.length > 1 ? (0, react.createElement)("select", {
2723
4552
  className: "lks-set-select",
2724
4553
  value: courseId,
2725
4554
  onChange: (e) => {
@@ -2761,25 +4590,34 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2761
4590
  setQuery(e.target.value);
2762
4591
  },
2763
4592
  onKeyDown: (e) => {
4593
+ if (e.key === "Escape") {
4594
+ setQuery("");
4595
+ setSearchRows([]);
4596
+ }
2764
4597
  if (e.key !== "Enter" || query.trim() === "") return;
2765
- for (const section of course.sections) {
2766
- const hit = section.lessons.find((l) => l.kind !== "exam" && l.status !== "locked" && titleMatches(l.title, query));
2767
- if (hit !== void 0) {
2768
- setFocus(hit.id).catch(reportError);
2769
- return;
2770
- }
4598
+ const first = searchRows[0];
4599
+ if (first !== void 0) {
4600
+ setQuery("");
4601
+ setSearchRows([]);
4602
+ setFocus(first.lessonId).catch(reportError);
2771
4603
  }
2772
- searchLessons(query).then((hits) => {
2773
- const hit = hits.find((h) => h.lessonId.startsWith(`${courseId}:`)) ?? hits[0];
2774
- if (hit !== void 0) {
2775
- setQuery("");
2776
- setFocus(hit.lessonId).catch(reportError);
2777
- }
2778
- }).catch(reportError);
2779
4604
  }
2780
- }), 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", {
4605
+ }), searchRows.length > 0 ? (0, react.createElement)("div", { className: "lks14-searchpanel" }, ...searchRows.map((row) => (0, react.createElement)("button", {
4606
+ key: row.lessonId,
4607
+ className: "lks14-searchrow",
4608
+ title: tr("rail.search.jump"),
4609
+ onClick: () => {
4610
+ setQuery("");
4611
+ setSearchRows([]);
4612
+ setFocus(row.lessonId).catch(reportError);
4613
+ }
4614
+ }, (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", {
2781
4615
  key: d.lessonId,
2782
- className: "lks14-dueitem"
4616
+ className: "lks14-dueitem",
4617
+ title: tr("review.jump"),
4618
+ onClick: () => {
4619
+ setFocus(d.lessonId).catch(reportError);
4620
+ }
2783
4621
  }, (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", {
2784
4622
  className: "lks-btn ghost",
2785
4623
  style: { marginTop: "6px" },
@@ -2790,34 +4628,23 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2790
4628
  const examAllowed = examOpen(section.lessons);
2791
4629
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
2792
4630
  if (lessons.length === 0) return [];
2793
- const open = query.trim() !== "" || sectionDefaultOpen(section);
2794
- return [(0, react.createElement)("button", {
4631
+ const open = query.trim() !== "" || effectiveOpen(section.title, sectionDefaultOpen(section), sectionOverrides);
4632
+ return [(0, react.createElement)(MapSectionView, {
2795
4633
  key: section.title,
2796
- type: "button",
2797
- className: "lks14-sechead",
2798
- "aria-expanded": String(open),
2799
- title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
2800
- onClick: () => {}
2801
- }, (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) => {
2802
- const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
2803
- return (0, react.createElement)("button", {
2804
- key: lesson.id,
2805
- type: "button",
2806
- className: `lks14-node${lesson.focus ? " focus" : ""}`,
2807
- "aria-disabled": locked || void 0,
2808
- title: `${lesson.title} — ${statusTitle(lesson.kind, locked ? "locked" : lesson.status)}`,
2809
- onClick: () => {
2810
- if (locked) return;
2811
- setFocus(lesson.id).catch(reportError);
2812
- }
2813
- }, (0, react.createElement)("span", { className: "lks14-g" }, statusIcon(lesson.kind, locked ? "locked" : lesson.status)), (0, react.createElement)("span", { className: "lks14-t" }, lesson.title), lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
2814
- className: "lks14-tag weak",
2815
- title: tr("tag.weak", { count: lesson.weakConcepts })
2816
- }, (0, react.createElement)(IconBoltFill16, { size: 10 }), String(lesson.weakConcepts)) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
2817
- className: "lks14-bar",
2818
- title: tr("tag.mastery", { pct: lesson.masteryPct })
2819
- }, (0, react.createElement)("i", { style: { transform: `scaleX(${lesson.masteryPct / 100})` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", { className: "lks14-pct" }, `${lesson.masteryPct}%`) : null);
2820
- }) : []];
4634
+ section: {
4635
+ title: section.title,
4636
+ index: section.index,
4637
+ lessons
4638
+ },
4639
+ examAllowed,
4640
+ open,
4641
+ onToggle: () => {
4642
+ toggleSection(section.title, !open);
4643
+ },
4644
+ onJump: (id) => {
4645
+ setFocus(id).catch(reportError);
4646
+ }
4647
+ })];
2821
4648
  }), (0, react.createElement)("button", {
2822
4649
  className: "lks-btn ghost",
2823
4650
  style: { marginTop: "10px" },
@@ -2850,7 +4677,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2850
4677
  }, tr("rail.empty.button"))), (0, react.createElement)("div", { className: "lks14-hint" }, tr("rail.empty.hint"), (0, react.createElement)("br"), tr("rail.empty.hint2")));
2851
4678
  }
2852
4679
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
2853
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, setMode }) {
4680
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, setMode, narrowPane, companionEvent }) {
2854
4681
  const streamEl = (0, react.useRef)(null);
2855
4682
  (0, react.useEffect)(() => {
2856
4683
  const el = streamEl.current;
@@ -2873,7 +4700,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2873
4700
  if (draft.trim() !== "" && !busy) send(draft);
2874
4701
  }
2875
4702
  };
2876
- 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", {
4703
+ const proposal = data?.pendingProposals[0] ?? null;
4704
+ 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", {
4705
+ className: "lks-btn primary",
4706
+ style: {
4707
+ padding: "4px 12px",
4708
+ fontSize: "12.5px"
4709
+ },
4710
+ onClick: () => {
4711
+ send(tr("proposal.accept.msg", { lesson: proposal.lessonTitle }));
4712
+ }
4713
+ }, tr("proposal.accept")), (0, react.createElement)("button", {
4714
+ className: "lks-btn ghost",
4715
+ style: {
4716
+ padding: "4px 12px",
4717
+ fontSize: "12.5px"
4718
+ },
4719
+ onClick: () => {
4720
+ send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
4721
+ }
4722
+ }, 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", {
2877
4723
  key: mode.id,
2878
4724
  className: `lks14-pill${data?.mode === mode.id ? " on" : ""}`,
2879
4725
  "aria-pressed": String(data?.mode === mode.id),
@@ -2886,7 +4732,21 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2886
4732
  className: "lks14-stream",
2887
4733
  ref: streamEl,
2888
4734
  "data-lks-feed": rows.length > 0 ? `rows:${String(rows.length)}` : bound ? feedAttached ? "attached-empty" : "waiting" : "no-thread"
2889
- }, 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", {
4735
+ }, 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)("i", null), (0, react.createElement)("i", null), (0, react.createElement)("i", null)) : null), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType === "quiz").map((a) => (0, react.createElement)(QuizCard, {
4736
+ key: a.id,
4737
+ lessonId: lesson.lessonId,
4738
+ artifactId: a.id,
4739
+ data: a.data,
4740
+ masteryPct: lesson.masteryPct,
4741
+ send,
4742
+ onFinished: (allCorrect) => {
4743
+ companionEvent(allCorrect ? "celebrate" : "encourage");
4744
+ }
4745
+ })), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(ArtifactCard, {
4746
+ key: a.id,
4747
+ artifact: a,
4748
+ send
4749
+ })), (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
2890
4750
  key: s.label,
2891
4751
  className: "lks14-starter",
2892
4752
  "aria-disabled": dormant || void 0,
@@ -2894,7 +4754,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2894
4754
  onClick: () => {
2895
4755
  if (!dormant) send(s.message);
2896
4756
  }
2897
- }, s.label))), (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("textarea", {
4757
+ }, s.label))), (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", { className: "lks14-composer-card" }, (0, react.createElement)("textarea", {
2898
4758
  className: "lks14-composertext",
2899
4759
  placeholder: busy ? tr("composer.busy") : tr("tutor.empty.hint"),
2900
4760
  value: draft,
@@ -2905,18 +4765,19 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2905
4765
  },
2906
4766
  onKeyDown: onComposerKey
2907
4767
  }), (0, react.createElement)("button", {
2908
- className: "lks-btn primary",
4768
+ className: "lks-btn-send",
4769
+ "aria-label": tr("composer.send"),
2909
4770
  disabled: busy || dormant || draft.trim() === "",
2910
4771
  onClick: () => {
2911
4772
  send(draft);
2912
4773
  }
2913
4774
  }, busy ? (0, react.createElement)(IconLoadingOutline16, {
2914
- size: 13,
4775
+ size: 16,
2915
4776
  className: "lks-spin"
2916
- }) : null, tr("composer.send"))), sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null);
4777
+ }) : "")), sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null));
2917
4778
  }
2918
4779
  /** 右栏:the notebook — 讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
2919
- function NotebookPane({ data, deleteNote }) {
4780
+ function NotebookPane({ data, deleteNote, send, companionEvent }) {
2920
4781
  const lesson = data?.lesson ?? null;
2921
4782
  const [tab, setTab] = (0, react.useState)("teach");
2922
4783
  const [error, setError] = (0, react.useState)(null);
@@ -2924,7 +4785,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2924
4785
  const [read, setRead] = (0, react.useState)(null);
2925
4786
  const readCtl = (0, react.useRef)(null);
2926
4787
  const [readError, setReadError] = (0, react.useState)(null);
2927
- const { tts } = useStudy();
4788
+ const { tts, addUserNote, recordReview } = useStudy();
2928
4789
  const proseRef = (0, react.useRef)(null);
2929
4790
  const diagRef = (0, react.useRef)(null);
2930
4791
  (0, react.useEffect)(() => {
@@ -2949,8 +4810,94 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
2949
4810
  setError(err instanceof Error ? err.message : String(err));
2950
4811
  });
2951
4812
  };
4813
+ const artifacts = lesson?.artifacts ?? [];
4814
+ const [, setUnseenTick] = (0, react.useState)(0);
4815
+ const unseen = unseenArtifacts(lesson?.lessonId ?? "", artifacts);
4816
+ const lastAnnounced = (0, react.useRef)(/* @__PURE__ */ new Set());
4817
+ (0, react.useEffect)(() => {
4818
+ const fresh = unseen.filter((id) => !lastAnnounced.current.has(id));
4819
+ if (fresh.length === 0) return;
4820
+ lastAnnounced.current = /* @__PURE__ */ new Set([...lastAnnounced.current, ...fresh]);
4821
+ showStudyToast(tr("artifact.sedimented"), { severity: "info" });
4822
+ setUnseenTick(Date.now());
4823
+ }, [unseen.join(","), lesson?.lessonId]);
4824
+ const heavy = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
4825
+ const latestHeavy = heavy.length > 0 ? heavy[heavy.length - 1] : null;
4826
+ const [quoteBtn, setQuoteBtn] = (0, react.useState)(null);
4827
+ const recordQuotes = (lesson?.notes ?? []).filter((n) => n.zone === "record" && n.quote !== null && n.quote.trim().length >= 2).map((n) => n.quote);
4828
+ const evaluateSelection = () => {
4829
+ const prose = proseRef.current;
4830
+ const selection = window.getSelection();
4831
+ if (prose === null || selection === null || selection.rangeCount === 0) return;
4832
+ const text = selection.toString().trim();
4833
+ if (text.length < 2 || text.length > 600) return;
4834
+ const range = selection.getRangeAt(0);
4835
+ if (prose.contains(range.commonAncestorContainer) !== true) return;
4836
+ const model = getTextModel(prose);
4837
+ const rect = range.getBoundingClientRect();
4838
+ const box = prose.getBoundingClientRect();
4839
+ const modelText = model.text;
4840
+ const idx = locateInModel(model, text, void 0)?.start ?? modelText.indexOf(text);
4841
+ const surrounding = idx >= 0 ? modelText.slice(Math.max(0, idx - 30), idx + text.length + 30) : text;
4842
+ setQuoteBtn({
4843
+ x: Math.min(Math.max(rect.left - box.left + rect.width / 2, 90), Math.max(box.width - 90, 90)),
4844
+ y: rect.top - box.top - 38,
4845
+ text,
4846
+ surrounding
4847
+ });
4848
+ };
4849
+ (0, react.useEffect)(() => {
4850
+ const SETTLE = 250;
4851
+ let settleTimer = null;
4852
+ let hideTimer = null;
4853
+ let gesture = false;
4854
+ const selectionHasText = () => (window.getSelection()?.toString().trim().length ?? 0) >= 2;
4855
+ const onChange = () => {
4856
+ if (selectionHasText()) {
4857
+ if (hideTimer !== null) {
4858
+ clearTimeout(hideTimer);
4859
+ hideTimer = null;
4860
+ }
4861
+ setQuoteBtn((cur) => cur === null ? cur : null);
4862
+ if (settleTimer !== null) clearTimeout(settleTimer);
4863
+ settleTimer = setTimeout(() => {
4864
+ settleTimer = null;
4865
+ if (!gesture && selectionHasText()) evaluateSelection();
4866
+ }, SETTLE);
4867
+ } else if (hideTimer === null) hideTimer = setTimeout(() => {
4868
+ setQuoteBtn((cur) => cur === null ? cur : null);
4869
+ }, 250);
4870
+ };
4871
+ const onDown = (e) => {
4872
+ if (e.target?.closest?.("[data-lks-quote-btn]") !== null) return;
4873
+ gesture = true;
4874
+ };
4875
+ const onUp = () => {
4876
+ gesture = false;
4877
+ if (settleTimer !== null) {
4878
+ clearTimeout(settleTimer);
4879
+ settleTimer = null;
4880
+ }
4881
+ if (hideTimer === null && selectionHasText()) evaluateSelection();
4882
+ };
4883
+ document.addEventListener("selectionchange", onChange);
4884
+ window.addEventListener("pointerdown", onDown, true);
4885
+ window.addEventListener("pointerup", onUp, true);
4886
+ window.addEventListener("pointercancel", onUp, true);
4887
+ return () => {
4888
+ document.removeEventListener("selectionchange", onChange);
4889
+ window.removeEventListener("pointerdown", onDown, true);
4890
+ window.removeEventListener("pointerup", onUp, true);
4891
+ window.removeEventListener("pointercancel", onUp, true);
4892
+ if (settleTimer !== null) clearTimeout(settleTimer);
4893
+ if (hideTimer !== null) clearTimeout(hideTimer);
4894
+ };
4895
+ }, []);
4896
+ const [enhanceTick, setEnhanceTick] = (0, react.useState)(0);
4897
+ const [rateBusy, setRateBusy] = (0, react.useState)(false);
2952
4898
  const startReading = () => {
2953
4899
  if (lesson === null || lesson.speechText.trim() === "") return;
4900
+ companionEvent("talk-start");
2954
4901
  readCtl.current?.stop();
2955
4902
  const sentences = speechSentencesOf(lesson.speechText);
2956
4903
  const voice = storedTtsVoice();
@@ -3031,6 +4978,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3031
4978
  });
3032
4979
  };
3033
4980
  const stopReading = () => {
4981
+ companionEvent("talk-end");
3034
4982
  readCtl.current?.stop();
3035
4983
  setRead(null);
3036
4984
  };
@@ -3051,7 +4999,10 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3051
4999
  ]);
3052
5000
  (0, react.useEffect)(() => {
3053
5001
  if (tab !== "teach" || proseRef.current === null || lesson === null) return;
3054
- enhanceRendered(proseRef.current).catch(() => {});
5002
+ enhanceRendered(proseRef.current).catch(() => {}).finally(() => {
5003
+ applyHighlights(proseRef.current, recordQuotes);
5004
+ setEnhanceTick(Date.now());
5005
+ });
3055
5006
  }, [tab, lesson?.html]);
3056
5007
  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", {
3057
5008
  className: `lks14-viewtab${tab === "teach" ? " on" : ""}`,
@@ -3071,8 +5022,34 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3071
5022
  "aria-pressed": String(tab === "notes"),
3072
5023
  onClick: () => {
3073
5024
  setTab("notes");
5025
+ if (lesson !== null && unseen.length > 0) {
5026
+ markArtifactsSeen(lesson.lessonId, unseen);
5027
+ setUnseenTick(Date.now());
5028
+ }
3074
5029
  }
3075
- }, tr("bb.notes"))), tab === "teach" ? (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
5030
+ }, 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", {
5031
+ className: "lks-ratecard",
5032
+ "data-lks-rate": lesson.lessonId
5033
+ }, (0, react.createElement)("div", { className: "lks-ratecard-title" }, tr("review.rate.title")), (0, react.createElement)("div", { className: "lks-ratecard-opts" }, ...[
5034
+ ["1", "review.again"],
5035
+ ["4", "review.remembered"],
5036
+ ["5", "review.mastered"]
5037
+ ].map(([q, key]) => (0, react.createElement)("button", {
5038
+ key: q,
5039
+ className: `lks-ratecard-opt${q === "1" ? " again" : q === "5" ? " best" : ""}`,
5040
+ disabled: rateBusy,
5041
+ onClick: () => {
5042
+ setRateBusy(true);
5043
+ recordReview(lesson.lessonId, Number(q)).then(() => {
5044
+ companionEvent(Number(q) >= 4 ? "celebrate" : "encourage");
5045
+ showStudyToast(Number(q) >= 4 ? tr("review.done.good", { days: 1 }) : tr("review.done.again"), { severity: Number(q) >= 4 ? "success" : "warning" });
5046
+ setRateBusy(false);
5047
+ }).catch((err) => {
5048
+ setError(err instanceof Error ? err.message : String(err));
5049
+ setRateBusy(false);
5050
+ });
5051
+ }
5052
+ }, tr(key))))) : null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
3076
5053
  className: "lks-btn ghost",
3077
5054
  style: {
3078
5055
  padding: "3px 8px",
@@ -3100,14 +5077,49 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3100
5077
  },
3101
5078
  title: tr("read.stop"),
3102
5079
  onClick: stopReading
3103
- }, 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", {
5080
+ }, 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", {
3104
5081
  className: "lks14-prose",
3105
5082
  ref: proseRef,
3106
5083
  dangerouslySetInnerHTML: { __html: lesson.html }
3107
- })) : tab === "cmap" ? (0, react.createElement)("div", {
5084
+ }), quoteBtn !== null ? (0, react.createElement)("div", {
5085
+ className: "lks-quote-btn",
5086
+ "data-lks-quote-btn": "",
5087
+ style: {
5088
+ left: `${quoteBtn.x}px`,
5089
+ top: `${quoteBtn.y}px`
5090
+ }
5091
+ }, (0, react.createElement)("button", { onClick: () => {
5092
+ send(tr("note.quote.template", { text: quoteBtn.text.length > 200 ? `${quoteBtn.text.slice(0, 200)}…` : quoteBtn.text }));
5093
+ setQuoteBtn(null);
5094
+ window.getSelection()?.removeAllRanges();
5095
+ } }, tr("note.quote.ask")), (0, react.createElement)("button", { onClick: () => {
5096
+ if (lesson !== null) {
5097
+ addUserNote(lesson.lessonId, quoteBtn.text).catch((err) => {
5098
+ setError(err instanceof Error ? err.message : String(err));
5099
+ });
5100
+ applyHighlights(proseRef.current, [quoteBtn.text]);
5101
+ showStudyToast(tr("note.saved"), { severity: "success" });
5102
+ }
5103
+ setQuoteBtn(null);
5104
+ window.getSelection()?.removeAllRanges();
5105
+ } }, tr("note.quote.save"))) : null), latestHeavy !== null ? (0, react.createElement)("div", { className: "lks-acard-stage" }, (0, react.createElement)(ArtifactCard, {
5106
+ artifact: latestHeavy,
5107
+ send: () => {}
5108
+ })) : null) : tab === "cmap" ? (0, react.createElement)("div", {
3108
5109
  className: "lks14-prose",
3109
5110
  ref: diagRef
3110
- }) : (0, react.createElement)("div", { className: "lks14-zones" }, lesson.notes.length === 0 ? (0, react.createElement)("div", {
5111
+ }) : (0, react.createElement)("div", { className: "lks14-zones" }, (() => {
5112
+ const understand = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
5113
+ if (understand.length === 0) return null;
5114
+ return (0, react.createElement)("div", {
5115
+ key: "artifacts",
5116
+ className: "lks14-zone"
5117
+ }, (0, react.createElement)("div", { className: "lks14-zoneh" }, tr("zone.artifacts")), ...understand.map((a) => (0, react.createElement)(ArtifactCard, {
5118
+ key: a.id,
5119
+ artifact: a,
5120
+ send: () => {}
5121
+ })));
5122
+ })(), lesson.notes.length === 0 ? (0, react.createElement)("div", {
3111
5123
  className: "lks14-empty",
3112
5124
  style: { padding: "16px 0" }
3113
5125
  }, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
@@ -3388,6 +5400,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3388
5400
  const { data, setMode, activate } = useStudy();
3389
5401
  const [error, setError] = (0, react.useState)(null);
3390
5402
  const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
5403
+ const [companion, setCompanion] = (0, react.useState)(storedCompanionForm);
3391
5404
  const fire = (action) => {
3392
5405
  action.then(() => {
3393
5406
  setError(null);
@@ -3408,7 +5421,17 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
3408
5421
  onClick: () => {
3409
5422
  if (data !== null) fire(activate(!data.active));
3410
5423
  }
3411
- }, 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", {
5424
+ }, 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", {
5425
+ className: "lks-set-select",
5426
+ value: companion,
5427
+ onChange: (e) => {
5428
+ storeCompanionForm(normalizeCompanionForm(e.target.value));
5429
+ setCompanion(normalizeCompanionForm(e.target.value));
5430
+ }
5431
+ }, ...COMPANION_FORMS.map((f) => (0, react.createElement)("option", {
5432
+ key: f.id,
5433
+ value: f.id
5434
+ }, 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", {
3412
5435
  className: "lks-set-select",
3413
5436
  value: voice,
3414
5437
  onChange: (e) => {