dsh-plugin-lookatstudy 0.15.0 → 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
@@ -18,8 +18,299 @@ window.__ModuleLoader__.load({
18
18
  //#endregion
19
19
  let react = require("react");
20
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
21
306
  //#region src/client/styles.ts
22
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
+ /**
23
314
  * Style injection for the study surfaces: one `<style>` element carrying the
24
315
  * `.lks14-*` panel classes (the sidebar-entry center-takeover, arranged like
25
316
  * upstream LookatStudy) plus the shared `.lks-*` classes the host-native
@@ -33,12 +324,6 @@ window.__ModuleLoader__.load({
33
324
  */
34
325
  /** Marker attribute proving the stylesheet is already in the document. */
35
326
  const STYLE_ID = "data-dsh-plugin-lookatstudy";
36
- /**
37
- * The study stylesheet. The center-takeover visibility follows the stardeck
38
- * panel doctrine: the shell view is display:none until the `<html>` active
39
- * attribute flips, and the host conversation column's own children are hidden
40
- * behind `!important` so React never fights the takeover.
41
- */
42
327
  const STUDY_CSS = `
43
328
  /* State inks: small colored text must clear WCAG AA (4.5:1), but the host's
44
329
  raw state tokens (success/warn primaries) sit at 2.3–2.6:1 on light
@@ -74,6 +359,8 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
74
359
  .lks14{position:relative;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
75
360
  .lks14 button{font-family:var(--dsw-font-family);cursor:pointer}
76
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}
77
364
  .lks14-col{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
78
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)}
79
366
 
@@ -90,22 +377,6 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
90
377
  .lks14-duebox{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;margin:8px 0;font-size:12.5px}
91
378
  .lks14-dueitem{display:flex;justify-content:space-between;gap:6px;padding:2px 0;color:var(--dsw-alias-label-secondary)}
92
379
  .lks14-over{color:var(--dsw-alias-state-error-primary)}
93
- .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}
94
- .lks14-sechead:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
95
- .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}
96
- .lks14-secheadt{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
97
- .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}
98
- .lks14-node:hover{background:var(--dsw-alias-bg-layer-1)}
99
- .lks14-node:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
100
- .lks14-node.focus{background:var(--dsw-alias-bg-layer-2)}
101
- .lks14-node[aria-disabled='true']{color:var(--dsw-alias-label-tertiary);cursor:default}
102
- .lks14-g{flex:none;display:inline-flex}
103
- .lks14-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
104
- .lks14-tag{flex:none;display:inline-flex;align-items:center;gap:2px;font-size:10.5px;border-radius:6px;padding:1px 4px}
105
- .lks14-tag.weak{color:var(--dsw-alias-state-warn-primary)}
106
- .lks14-bar{flex:none;width:26px;height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
107
- .lks14-bar i{display:block;height:100%;background:var(--dsw-alias-business-primary);transform-origin:left}
108
- .lks14-pct{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
109
380
  .lks14-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;padding:18px 6px;line-height:1.7}
110
381
  .lks14-import{margin:8px 0}
111
382
  .lks14-inputrow{display:flex;gap:6px}
@@ -220,7 +491,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
220
491
  .lks14-rail{flex:none;max-height:32vh;border-right:none;border-bottom:1px solid var(--dsw-alias-border-l1)}
221
492
  .lks14-chat{min-width:0}
222
493
  .lks14-note{flex:1 1 auto;min-height:0}
223
- .lks14-body[data-pane='rail'] .lks14-chat,.lks14-body[data-pane='rail'] .lks14-note,
494
+ .lks14-body[data-pane='rail'] .lks14-righthalf{display:none}
224
495
  .lks14-body[data-pane='chat'] .lks14-rail,.lks14-body[data-pane='chat'] .lks14-note,
225
496
  .lks14-body[data-pane='note'] .lks14-rail,.lks14-body[data-pane='note'] .lks14-chat{display:none}
226
497
  .lks14-body[data-pane='rail'] .lks14-rail{display:flex;max-height:none;flex:1 1 auto}
@@ -394,7 +665,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
394
665
  if (existing !== null) return existing;
395
666
  const style = document.createElement("style");
396
667
  style.setAttribute(STYLE_ID, "");
397
- style.textContent = STUDY_CSS;
668
+ style.textContent = STUDY_CSS + UPSTREAM_CSS;
398
669
  document.head.appendChild(style);
399
670
  return style;
400
671
  }
@@ -1656,8 +1927,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1656
1927
  "col.tutor": "导师",
1657
1928
  "col.bb": "黑板",
1658
1929
  "pane.rail": "课程",
1659
- "pane.tutor": "导师",
1660
- "pane.bb": "黑板",
1930
+ "pane.chat": "对话",
1931
+ "pane.note": "黑板",
1661
1932
  "viewtab.teach": "讲解",
1662
1933
  "viewtab.cmap": "概念图",
1663
1934
  "viewtab.cmap.title": "本课概念之间的关系图",
@@ -1724,9 +1995,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1724
1995
  "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
1725
1996
  "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
1726
1997
  "tutor.thinking": "导师思考中…",
1727
- "pane.rail": "课程",
1728
- "pane.chat": "对话",
1729
- "pane.note": "黑板",
1998
+ "header.xp": "经验值 {xp}",
1999
+ "header.streak": "连续学习天数",
2000
+ "header.level": "等级",
2001
+ "map.node.locked": "锁定 — 先完成前面的课时",
2002
+ "map.node.due": "到了复习时间",
2003
+ "map.exam.locked": "考试未解锁 — 本章节全部课时掌握度 ≥50% 后开放",
1730
2004
  "companion.poke": "戳一戳伴学伙伴",
1731
2005
  "companion.poked": "伴学伙伴向你眨了眨眼",
1732
2006
  "companion.form.ember": "小焰",
@@ -1856,8 +2130,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1856
2130
  "col.tutor": "Tutor",
1857
2131
  "col.bb": "Blackboard",
1858
2132
  "pane.rail": "Courses",
1859
- "pane.tutor": "Tutor",
1860
- "pane.bb": "Board",
2133
+ "pane.chat": "Chat",
2134
+ "pane.note": "Board",
1861
2135
  "viewtab.teach": "Teach",
1862
2136
  "viewtab.cmap": "Concepts",
1863
2137
  "viewtab.cmap.title": "The lesson's concepts relate",
@@ -1924,9 +2198,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1924
2198
  "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
1925
2199
  "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
1926
2200
  "tutor.thinking": "The tutor is thinking…",
1927
- "pane.rail": "Course",
1928
- "pane.chat": "Chat",
1929
- "pane.note": "Notebook",
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",
1930
2207
  "companion.poke": "Poke the companion",
1931
2208
  "companion.poked": "The companion winks at you",
1932
2209
  "companion.form.ember": "Ember",
@@ -3592,6 +3869,248 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3592
3869
  return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
3593
3870
  }
3594
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
3595
4114
  //#region src/client/panel.tsx
3596
4115
  /**
3597
4116
  * The study panel — the sidebar-entry surface that takes over the center
@@ -3630,13 +4149,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3630
4149
  ["record", "zone.record"],
3631
4150
  ["practice", "zone.practice"]
3632
4151
  ];
3633
- function statusIcon(kind, status) {
3634
- if (kind === "exam") return (0, react.createElement)(IconGoalOutline16, { size: 14 });
3635
- if (status === "mastered") return (0, react.createElement)(IconCrownFill16, { size: 14 });
3636
- if (status === "in_progress") return (0, react.createElement)(IconBookFill16, { size: 14 });
3637
- if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
3638
- return (0, react.createElement)(IconLockFill16, { size: 14 });
3639
- }
3640
4152
  /** Toast severity glyph (P6): the state color rides the icon, text stays ink. */
3641
4153
  function toastIcon(severity) {
3642
4154
  if (severity === "success") return (0, react.createElement)(IconStarFill16, {
@@ -3855,6 +4367,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3855
4367
  };
3856
4368
  const companion = useCompanionMood();
3857
4369
  const setCompanionEvent = companion.emit;
4370
+ const progress = data?.progress ?? null;
3858
4371
  const body = (0, react.createElement)("div", {
3859
4372
  className: "lks14-body",
3860
4373
  "data-pane": narrowPane
@@ -3865,7 +4378,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3865
4378
  searchLessons,
3866
4379
  deleteCourse,
3867
4380
  send
3868
- }), (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, {
3869
4391
  data,
3870
4392
  lesson,
3871
4393
  rows,
@@ -3884,9 +4406,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3884
4406
  deleteNote,
3885
4407
  send,
3886
4408
  companionEvent: setCompanionEvent
3887
- }));
4409
+ }))));
3888
4410
  return (0, react.createElement)("div", {
3889
- className: "lks14",
4411
+ className: "lks14 lks-ui",
3890
4412
  "data-lks-panel": ""
3891
4413
  }, (0, react.createElement)(Companion, {
3892
4414
  mood: companion.mood,
@@ -4026,7 +4548,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4026
4548
  }, (0, react.createElement)(IconDownloadOutline16, null), tr("rail.empty.demo")), (0, react.createElement)(ImportRow, { send })) : (() => {
4027
4549
  const courseId = data.courses.some((c) => c.courseId === selectedCourse) ? selectedCourse : data.courses[0].courseId;
4028
4550
  const course = data.courses.find((c) => c.courseId === courseId);
4029
- 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", {
4030
4552
  className: "lks-set-select",
4031
4553
  value: courseId,
4032
4554
  onChange: (e) => {
@@ -4107,35 +4629,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4107
4629
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
4108
4630
  if (lessons.length === 0) return [];
4109
4631
  const open = query.trim() !== "" || effectiveOpen(section.title, sectionDefaultOpen(section), sectionOverrides);
4110
- return [(0, react.createElement)("button", {
4632
+ return [(0, react.createElement)(MapSectionView, {
4111
4633
  key: section.title,
4112
- type: "button",
4113
- className: "lks14-sechead",
4114
- "aria-expanded": String(open),
4115
- title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
4116
- onClick: () => {
4634
+ section: {
4635
+ title: section.title,
4636
+ index: section.index,
4637
+ lessons
4638
+ },
4639
+ examAllowed,
4640
+ open,
4641
+ onToggle: () => {
4117
4642
  toggleSection(section.title, !open);
4643
+ },
4644
+ onJump: (id) => {
4645
+ setFocus(id).catch(reportError);
4118
4646
  }
4119
- }, (0, react.createElement)("span", { className: "lks14-secnum" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks14-secheadt" }, section.title), (0, react.createElement)("span", { className: "lks14-secheadc" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
4120
- const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
4121
- return (0, react.createElement)("button", {
4122
- key: lesson.id,
4123
- type: "button",
4124
- className: `lks14-node${lesson.focus ? " focus" : ""}`,
4125
- "aria-disabled": locked || void 0,
4126
- title: `${lesson.title} — ${statusTitle(lesson.kind, locked ? "locked" : lesson.status)}`,
4127
- onClick: () => {
4128
- if (locked) return;
4129
- setFocus(lesson.id).catch(reportError);
4130
- }
4131
- }, (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", {
4132
- className: "lks14-tag weak",
4133
- title: tr("tag.weak", { count: lesson.weakConcepts })
4134
- }, (0, react.createElement)(IconBoltFill16, { size: 10 }), String(lesson.weakConcepts)) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
4135
- className: "lks14-bar",
4136
- title: tr("tag.mastery", { pct: lesson.masteryPct })
4137
- }, (0, react.createElement)("i", { style: { transform: `scaleX(${lesson.masteryPct / 100})` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", { className: "lks14-pct" }, `${lesson.masteryPct}%`) : null);
4138
- }) : []];
4647
+ })];
4139
4648
  }), (0, react.createElement)("button", {
4140
4649
  className: "lks-btn ghost",
4141
4650
  style: { marginTop: "10px" },
@@ -4223,10 +4732,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4223
4732
  className: "lks14-stream",
4224
4733
  ref: streamEl,
4225
4734
  "data-lks-feed": rows.length > 0 ? `rows:${String(rows.length)}` : bound ? feedAttached ? "attached-empty" : "waiting" : "no-thread"
4226
- }, rows.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, dormant ? tr("tutor.dormant") : tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRow(row, !dormant && i === lastAssistant ? { send } : void 0)), rows.length > 0 && rows[rows.length - 1].role === "user" ? (0, react.createElement)("div", { className: "lks14-thinking" }, (0, react.createElement)(IconLoadingOutline16, {
4227
- size: 13,
4228
- className: "lks-spin"
4229
- }), tr("tutor.thinking")) : null), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType === "quiz").map((a) => (0, react.createElement)(QuizCard, {
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, {
4230
4736
  key: a.id,
4231
4737
  lessonId: lesson.lessonId,
4232
4738
  artifactId: a.id,
@@ -4248,7 +4754,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4248
4754
  onClick: () => {
4249
4755
  if (!dormant) send(s.message);
4250
4756
  }
4251
- }, 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", {
4252
4758
  className: "lks14-composertext",
4253
4759
  placeholder: busy ? tr("composer.busy") : tr("tutor.empty.hint"),
4254
4760
  value: draft,
@@ -4259,15 +4765,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4259
4765
  },
4260
4766
  onKeyDown: onComposerKey
4261
4767
  }), (0, react.createElement)("button", {
4262
- className: "lks-btn primary",
4768
+ className: "lks-btn-send",
4769
+ "aria-label": tr("composer.send"),
4263
4770
  disabled: busy || dormant || draft.trim() === "",
4264
4771
  onClick: () => {
4265
4772
  send(draft);
4266
4773
  }
4267
4774
  }, busy ? (0, react.createElement)(IconLoadingOutline16, {
4268
- size: 13,
4775
+ size: 16,
4269
4776
  className: "lks-spin"
4270
- }) : 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));
4271
4778
  }
4272
4779
  /** 右栏:the notebook — 讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
4273
4780
  function NotebookPane({ data, deleteNote, send, companionEvent }) {