dsh-nerd-mode 0.4.4 → 0.4.6

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/README.md CHANGED
@@ -213,6 +213,8 @@ Change the defaults under Settings → Nerd Mode.
213
213
 
214
214
  A four-option question appears as a card with one correct choice. The guide asks a short-answer question in the conversation. Save applies to the next session, Cancel discards a draft, and Restore defaults returns to recall off, 3 questions, short answer. A preference stated inside one `/study-plus` request wins for that session only and does not update the profile.
215
215
 
216
+ The About group at the bottom shows the installed and the newest npm version. Its button installs an update through DSH Market and offers a restart when the host allows one. The whole page follows the DSH interface language.
217
+
216
218
  ## How it fits into dsh
217
219
 
218
220
  Nerd Mode is a profile-level plugin, mounted from `cordis.patch.yml`. The patch covers the whole profile, so every mode gains the command and the pill.
package/README.zh.md CHANGED
@@ -213,6 +213,8 @@ HTML 书页会在每次批量写稿和每节课后刷新,每节一页,还没
213
213
 
214
214
  四选一以卡片出现,四个选项里只有一个对;简答直接在对话里问。保存从下一节课开始生效,取消只丢草稿,恢复默认回到关闭、3 题、简答。在一次 `/study-plus` 请求里明确说出的偏好,只对那一次会话生效,不会改 profile。
215
215
 
216
+ 底部的「关于」显示已安装版本和 npm 上的最新版本。点按钮会通过 DSH Market 安装更新;宿主允许时还会给出重启按钮。整页文案跟随 DSH 界面语言。
217
+
216
218
  ## 它怎么接进 dsh
217
219
 
218
220
  Nerd Mode 是一个挂在 profile 层的插件,由 `cordis.patch.yml` 装入。补丁覆盖整个 profile,所以每个模式都有命令和胶囊。
package/client.js CHANGED
@@ -59,7 +59,110 @@ window.__ModuleLoader__.load({
59
59
  && left.unseenQuestionType === right.unseenQuestionType;
60
60
  }
61
61
 
62
- function NerdModeSettingsEntry({ configForms, close }) {
62
+ const MARKET_API = '/dsh-market/api/v1';
63
+ const PACKAGE = 'dsh-nerd-mode';
64
+ const UPDATE_SCHEMA = 'dsh-market/update-api/v1';
65
+ const REQUEST_TIMEOUT = 10000;
66
+ const POLL_INTERVAL = 1000;
67
+
68
+ function useT(locale) {
69
+ React.useSyncExternalStore(
70
+ (listener) => locale.subscribe(listener),
71
+ () => locale.getSnapshot(),
72
+ () => locale.getSnapshot(),
73
+ );
74
+ return (text) => locale.resolveText(text);
75
+ }
76
+
77
+ // Every Market call carries the session cookies and gives up after 10 s.
78
+ async function marketRequest(url, options = {}, life = null) {
79
+ const controller = new AbortController();
80
+ if (life !== null) life.controller = controller;
81
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
82
+ try {
83
+ const response = await fetch(url, {
84
+ credentials: 'same-origin',
85
+ ...options,
86
+ signal: controller.signal,
87
+ });
88
+ const body = await response.json().catch(() => null);
89
+ return { ok: response.ok, status: response.status, body };
90
+ } catch (error) {
91
+ if (controller.signal.aborted) throw new Error('DSH Market did not answer within 10 seconds.');
92
+ throw error;
93
+ } finally {
94
+ clearTimeout(timer);
95
+ if (life !== null && life.controller === controller) life.controller = null;
96
+ }
97
+ }
98
+
99
+ function marketFailure(result, fallback) {
100
+ const body = result?.body ?? {};
101
+ return body.error
102
+ ?? body.failure?.message
103
+ ?? body.failure?.code
104
+ ?? `${fallback} (HTTP ${result?.status ?? 0}).`;
105
+ }
106
+
107
+ // The schema is optional on the operation endpoints, but a wrong one is a different API.
108
+ const wrongSchema = (body) => body?.schema !== undefined && body.schema !== UPDATE_SCHEMA;
109
+
110
+ async function readCapabilities(life) {
111
+ const result = await marketRequest(`${MARKET_API}/capabilities`, {}, life);
112
+ const body = result.body ?? {};
113
+ if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not answer'));
114
+ if (body.schema !== UPDATE_SCHEMA || body.features?.update !== true) {
115
+ throw new Error('DSH Market does not support plugin updates.');
116
+ }
117
+ const endpoints = body.endpoints ?? {};
118
+ if (typeof endpoints.updates !== 'string' || typeof endpoints.operations !== 'string') {
119
+ throw new Error('DSH Market returned no update endpoints.');
120
+ }
121
+ return {
122
+ endpoints: { updates: endpoints.updates, operations: endpoints.operations },
123
+ profile: typeof body.profile === 'string' ? body.profile : null,
124
+ restart: body.features.restart === true && typeof endpoints.restart === 'string' ? endpoints.restart : null,
125
+ };
126
+ }
127
+
128
+ async function readRelease(market, force, life) {
129
+ const url = `${market.endpoints.updates}?name=${encodeURIComponent(PACKAGE)}${force ? '&force=1' : ''}`;
130
+ const result = await marketRequest(url, {}, life);
131
+ const body = result.body ?? {};
132
+ if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not check for an update'));
133
+ if (body.schema !== UPDATE_SCHEMA || typeof body.package !== 'object' || body.package === null) {
134
+ throw new Error('DSH Market returned no release for this plugin.');
135
+ }
136
+ return body.package;
137
+ }
138
+
139
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
140
+
141
+ // Only a plain `major.minor.patch` pair can prove the local copy is newer.
142
+ function compareVersions(left, right) {
143
+ const first = VERSION.exec(String(left ?? ''));
144
+ const second = VERSION.exec(String(right ?? ''));
145
+ if (first === null || second === null) return 0;
146
+ for (let at = 1; at <= 3; at += 1) {
147
+ const difference = Number(first[at]) - Number(second[at]);
148
+ if (difference !== 0) return difference;
149
+ }
150
+ return 0;
151
+ }
152
+
153
+ const manualCommand = (profile) => `dsh plugin --profile ${profile ?? '<profile>'} add ${PACKAGE}`;
154
+
155
+ function NerdModeSettingsEntry({ configForms, close, locale }) {
156
+ return React.createElement(
157
+ 'div',
158
+ { 'data-nerd-mode-settings': '', style: settingsColumnStyle },
159
+ React.createElement(NerdModeSettingsBody, { configForms, close, locale }),
160
+ React.createElement(AboutGroup, { locale }),
161
+ );
162
+ }
163
+
164
+ function NerdModeSettingsBody({ configForms, close, locale }) {
165
+ const t = useT(locale);
63
166
  const description = configForms.describe();
64
167
  const described = React.useSyncExternalStore(
65
168
  (listener) => description.subscribe(listener),
@@ -69,30 +172,32 @@ window.__ModuleLoader__.load({
69
172
  const settingsExist = described.view?.namespaces.some(({ ns }) => ns === SETTINGS_NAMESPACE) === true;
70
173
  if (!settingsExist) {
71
174
  const loading = (described.status === 'idle' || described.status === 'loading') && !described.error;
72
- return React.createElement('div', { 'data-nerd-mode-settings': '' },
175
+ return React.createElement(React.Fragment, null,
73
176
  React.createElement('p', {
74
177
  role: 'status',
75
178
  'aria-busy': loading,
76
179
  style: feedbackStyle,
77
180
  }, loading
78
- ? 'Loading Nerd Mode settings…'
181
+ ? t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' })
79
182
  : described.status === 'ready'
80
- ? 'Nerd Mode settings are unavailable in this profile.'
81
- : 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
183
+ ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
184
+ : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
82
185
  loading ? null : React.createElement('button', {
83
186
  type: 'button',
84
187
  onClick: close,
85
188
  style: secondaryButtonStyle,
86
- }, 'Close'),
189
+ }, t({ en: 'Close', zh: '关闭' })),
87
190
  );
88
191
  }
89
192
  return React.createElement(NerdModeSettings, {
90
193
  close,
194
+ locale,
91
195
  scope: configForms.get(SETTINGS_NAMESPACE),
92
196
  });
93
197
  }
94
198
 
95
- function NerdModeSettings({ scope, close }) {
199
+ function NerdModeSettings({ scope, close, locale }) {
200
+ const t = useT(locale);
96
201
  const snapshot = React.useSyncExternalStore(
97
202
  (listener) => scope.subscribe(listener),
98
203
  () => scope.getSnapshot(),
@@ -111,19 +216,19 @@ window.__ModuleLoader__.load({
111
216
  role: 'status',
112
217
  'aria-busy': true,
113
218
  style: feedbackStyle,
114
- }, 'Loading Nerd Mode settings…');
219
+ }, t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' }));
115
220
  }
116
221
  if (snapshot?.status !== 'ready') {
117
- return React.createElement('div', { 'data-nerd-mode-settings': '' },
222
+ return React.createElement(React.Fragment, null,
118
223
  React.createElement('p', { role: 'status', style: feedbackStyle },
119
224
  snapshot?.status === 'unavailable'
120
- ? 'Nerd Mode settings are unavailable in this profile.'
121
- : 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
225
+ ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
226
+ : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
122
227
  React.createElement('button', {
123
228
  type: 'button',
124
229
  onClick: close,
125
230
  style: secondaryButtonStyle,
126
- }, 'Close'),
231
+ }, t({ en: 'Close', zh: '关闭' })),
127
232
  );
128
233
  }
129
234
  const current = settingsValues(snapshot);
@@ -153,38 +258,39 @@ window.__ModuleLoader__.load({
153
258
  setFeedback(null);
154
259
  try {
155
260
  const accepted = await scope.mutate(operations, latest.revision);
156
- if (accepted === false) throw new Error('The profile rejected these settings.');
261
+ if (accepted === false) {
262
+ throw new Error(t({ en: 'The profile rejected these settings.', zh: 'profile 拒绝了这些设置。' }));
263
+ }
157
264
  setDraft(settingsValues(scope.getSnapshot()));
158
- setFeedback('Saved to this profile. Changes apply to the next study-plus session.');
265
+ setFeedback({
266
+ tone: 'status',
267
+ text: {
268
+ en: 'Saved to this profile. Changes apply to the next study-plus session.',
269
+ zh: '已保存到此 profile。更改将在下次 study-plus 会话中生效。',
270
+ },
271
+ });
159
272
  } catch (error) {
160
- setFeedback(`Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`);
273
+ setFeedback({
274
+ tone: 'alert',
275
+ text: {
276
+ en: `Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`,
277
+ zh: `无法保存设置:${error?.message ?? 'profile 拒绝了这些更改'}`,
278
+ },
279
+ });
161
280
  } finally {
162
281
  setSaving(false);
163
282
  }
164
283
  };
165
284
 
166
285
  return React.createElement(
167
- 'div',
168
- {
169
- 'data-nerd-mode-settings': '',
170
- style: {
171
- boxSizing: 'border-box',
172
- color: 'var(--dsw-alias-label-primary)',
173
- display: 'flex',
174
- flexDirection: 'column',
175
- fontFamily: 'var(--dsw-font-family, inherit)',
176
- gap: 16,
177
- maxWidth: 720,
178
- paddingBottom: 24,
179
- width: '100%',
180
- },
181
- },
286
+ React.Fragment,
287
+ null,
182
288
  React.createElement(
183
289
  'header',
184
290
  { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
185
291
  React.createElement('h2', {
186
292
  style: { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 },
187
- }, 'Nerd Mode'),
293
+ }, t({ en: 'Nerd Mode', zh: 'Nerd Mode' })),
188
294
  React.createElement('p', {
189
295
  style: {
190
296
  color: 'var(--dsw-alias-label-tertiary)',
@@ -192,7 +298,7 @@ window.__ModuleLoader__.load({
192
298
  lineHeight: '22px',
193
299
  margin: 0,
194
300
  },
195
- }, 'Profile defaults for new study-plus sessions.'),
301
+ }, t({ en: 'Profile defaults for new study-plus sessions.', zh: '新 study-plus 会话的 profile 默认值。' })),
196
302
  ),
197
303
  React.createElement(
198
304
  'section',
@@ -203,8 +309,8 @@ window.__ModuleLoader__.load({
203
309
  React.createElement(
204
310
  'div',
205
311
  { style: { minWidth: 0 } },
206
- React.createElement('h3', { style: groupTitleStyle }, 'Recall Session / 课前回忆'),
207
- React.createElement('p', { style: groupDescriptionStyle }, 'One ungraded recall question before teaching.'),
312
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Recall Session / 课前回忆', zh: '课前回忆' })),
313
+ React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'One ungraded recall question before teaching.', zh: '讲授前的一次不计分课前回忆。' })),
208
314
  ),
209
315
  React.createElement(
210
316
  'button',
@@ -212,14 +318,16 @@ window.__ModuleLoader__.load({
212
318
  type: 'button',
213
319
  role: 'switch',
214
320
  'aria-checked': draft.recallSessionEnabled,
215
- 'aria-label': 'Recall Session',
321
+ 'aria-label': t({ en: 'Recall Session', zh: '课前回忆' }),
216
322
  'data-setting': 'recallSessionEnabled',
217
323
  'data-state': draft.recallSessionEnabled ? 'on' : 'off',
218
324
  disabled: !writable || saving,
219
325
  onClick: () => updateDraft('recallSessionEnabled', !draft.recallSessionEnabled),
220
326
  style: switchStyle(draft.recallSessionEnabled, !writable || saving),
221
327
  },
222
- React.createElement('span', null, draft.recallSessionEnabled ? 'On' : 'Off'),
328
+ React.createElement('span', null, draft.recallSessionEnabled
329
+ ? t({ en: 'On', zh: '开' })
330
+ : t({ en: 'Off', zh: '关' })),
223
331
  React.createElement('span', {
224
332
  'aria-hidden': 'true',
225
333
  style: {
@@ -241,8 +349,8 @@ window.__ModuleLoader__.load({
241
349
  React.createElement(
242
350
  'div',
243
351
  null,
244
- React.createElement('h3', { style: groupTitleStyle }, 'Post-lecture questions'),
245
- React.createElement('p', { style: groupDescriptionStyle }, 'Choose how many unseen questions follow each lecture.'),
352
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Post-lecture questions', zh: '课后提问' })),
353
+ React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'Choose how many unseen questions follow each lecture.', zh: '选择每节课后出几道题。' })),
246
354
  ),
247
355
  React.createElement(
248
356
  'div',
@@ -250,7 +358,7 @@ window.__ModuleLoader__.load({
250
358
  React.createElement(
251
359
  'label',
252
360
  { htmlFor: 'nerd-mode-question-count', style: fieldLabelStyle },
253
- React.createElement('span', null, 'Question count'),
361
+ React.createElement('span', null, t({ en: 'Question count', zh: '题数' })),
254
362
  React.createElement('input', {
255
363
  id: 'nerd-mode-question-count',
256
364
  type: 'number',
@@ -264,12 +372,12 @@ window.__ModuleLoader__.load({
264
372
  onChange: (event) => updateDraft('unseenQuestionCount', event.target.value),
265
373
  style: fieldControlStyle,
266
374
  }),
267
- React.createElement('span', { style: fieldHintStyle }, 'Use 0 to skip unseen questions.'),
375
+ React.createElement('span', { style: fieldHintStyle }, t({ en: 'Use 0 to skip unseen questions.', zh: '设为 0 可跳过课后提问。' })),
268
376
  ),
269
377
  React.createElement(
270
378
  'label',
271
379
  { htmlFor: 'nerd-mode-question-type', style: fieldLabelStyle },
272
- React.createElement('span', null, 'Question type'),
380
+ React.createElement('span', null, t({ en: 'Question type', zh: '题型' })),
273
381
  React.createElement(
274
382
  'select',
275
383
  {
@@ -280,11 +388,11 @@ window.__ModuleLoader__.load({
280
388
  onChange: (event) => updateDraft('unseenQuestionType', event.target.value),
281
389
  style: fieldControlStyle,
282
390
  },
283
- React.createElement('option', { value: 'short-answer' }, 'Short answer'),
284
- React.createElement('option', { value: 'multiple-choice' }, 'Multiple choice'),
285
- React.createElement('option', { value: 'mixed' }, 'Mixed'),
391
+ React.createElement('option', { value: 'short-answer' }, t({ en: 'Short answer', zh: '简答' })),
392
+ React.createElement('option', { value: 'multiple-choice' }, t({ en: 'Multiple choice', zh: '四选一' })),
393
+ React.createElement('option', { value: 'mixed' }, t({ en: 'Mixed', zh: '混合' })),
286
394
  ),
287
- React.createElement('span', { style: fieldHintStyle }, 'Mixed chooses a type for each question.'),
395
+ React.createElement('span', { style: fieldHintStyle }, t({ en: 'Mixed chooses a type for each question.', zh: '混合会为每道题随机选题型。' })),
288
396
  ),
289
397
  ),
290
398
  ),
@@ -306,7 +414,7 @@ window.__ModuleLoader__.load({
306
414
  disabled: !writable || saving,
307
415
  onClick: restoreDefaults,
308
416
  style: secondaryButtonStyle,
309
- }, 'Restore defaults'),
417
+ }, t({ en: 'Restore defaults', zh: '恢复默认' })),
310
418
  React.createElement(
311
419
  'div',
312
420
  { style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
@@ -315,27 +423,301 @@ window.__ModuleLoader__.load({
315
423
  disabled: !dirty || saving,
316
424
  onClick: cancel,
317
425
  style: secondaryButtonStyle,
318
- }, 'Cancel'),
426
+ }, t({ en: 'Cancel', zh: '取消' })),
319
427
  React.createElement('button', {
320
428
  type: 'button',
321
429
  disabled: !dirty || !validSettings(draft) || !writable || saving,
322
430
  onClick: save,
323
431
  style: primaryButtonStyle,
324
- }, saving ? 'Saving…' : 'Save'),
432
+ }, saving ? t({ en: 'Saving…', zh: '保存中…' }) : t({ en: 'Save', zh: '保存' })),
325
433
  ),
326
434
  ),
327
435
  snapshot?.writable === false
328
- ? React.createElement('p', { role: 'status', style: feedbackStyle }, 'These profile settings are read-only.')
436
+ ? React.createElement('p', { role: 'status', style: feedbackStyle }, t({ en: 'These profile settings are read-only.', zh: '这些 profile 设置为只读。' }))
329
437
  : null,
330
438
  feedback === null
331
439
  ? null
332
440
  : React.createElement('p', {
333
- role: feedback.startsWith('Could not save') ? 'alert' : 'status',
441
+ role: feedback.tone,
334
442
  style: feedbackStyle,
335
- }, feedback),
443
+ }, t(feedback.text)),
444
+ );
445
+ }
446
+
447
+ function AboutGroup({ locale }) {
448
+ const t = useT(locale);
449
+ const [life] = React.useState(() => ({ cancelled: false, timer: null, controller: null }));
450
+ const [view, setView] = React.useState({ phase: 'checking' });
451
+
452
+ // An answer that arrives after the panel closed writes nothing.
453
+ const show = (next) => {
454
+ if (!life.cancelled) setView(next);
455
+ };
456
+
457
+ async function check(force) {
458
+ setView({ phase: 'checking' });
459
+ let market;
460
+ try {
461
+ market = await readCapabilities(life);
462
+ } catch {
463
+ show({ phase: 'unavailable' });
464
+ return;
465
+ }
466
+ try {
467
+ const release = await readRelease(market, force, life);
468
+ show({ phase: 'ready', market, release, confirming: false, linkedNote: false });
469
+ } catch (error) {
470
+ show({
471
+ phase: 'failed',
472
+ market,
473
+ release: null,
474
+ message: error?.message ?? 'The update check failed.',
475
+ retry: 'check',
476
+ force: false,
477
+ });
478
+ }
479
+ }
480
+
481
+ function follow(market, operationId, release) {
482
+ return new Promise((resolve, reject) => {
483
+ const tick = async () => {
484
+ if (life.cancelled) {
485
+ resolve(null);
486
+ return;
487
+ }
488
+ let result;
489
+ try {
490
+ result = await marketRequest(`${market.endpoints.operations}?operationId=${encodeURIComponent(operationId)}`, {}, life);
491
+ } catch (error) {
492
+ reject(error);
493
+ return;
494
+ }
495
+ if (life.cancelled) {
496
+ resolve(null);
497
+ return;
498
+ }
499
+ if (!result.ok || wrongSchema(result.body)) {
500
+ reject(new Error(marketFailure(result, 'The update could not be followed')));
501
+ return;
502
+ }
503
+ const operation = result.body?.operation ?? {};
504
+ if (operation.state === 'succeeded') {
505
+ resolve(operation);
506
+ return;
507
+ }
508
+ if (operation.state === 'failed' || operation.state === 'cancelled' || operation.state === 'rolled-back') {
509
+ const error = new Error(operation.failure?.message ?? operation.failure?.code ?? `The update ${operation.state}.`);
510
+ error.code = operation.failure?.code ?? null;
511
+ reject(error);
512
+ return;
513
+ }
514
+ show({ phase: 'updating', market, release, progress: operation.state ?? 'running' });
515
+ life.timer = setTimeout(tick, POLL_INTERVAL);
516
+ };
517
+ tick();
518
+ });
519
+ }
520
+
521
+ async function install(market, release, force) {
522
+ setView({ phase: 'updating', market, release, progress: 'queued' });
523
+ try {
524
+ const result = await marketRequest(market.endpoints.updates, {
525
+ method: 'POST',
526
+ headers: { 'content-type': 'application/json' },
527
+ body: JSON.stringify({ packageName: PACKAGE, ...(force ? { force: true } : {}) }),
528
+ }, life);
529
+ if (!result.ok || wrongSchema(result.body)) {
530
+ const error = new Error(marketFailure(result, 'The update did not start'));
531
+ error.code = result.body?.failure?.code ?? null;
532
+ throw error;
533
+ }
534
+ const operationId = result.body?.operation?.operationId;
535
+ if (typeof operationId !== 'string') throw new Error('DSH Market returned no operation to follow.');
536
+ const operation = await follow(market, operationId, release);
537
+ if (operation === null) return;
538
+ show({
539
+ phase: 'installed',
540
+ market,
541
+ release,
542
+ version: operation.installedVersion ?? release.latestVersion,
543
+ restartNeeded: operation.outcome?.restartRequired === true,
544
+ restarting: false,
545
+ restartSent: false,
546
+ });
547
+ } catch (error) {
548
+ const code = error?.code ?? null;
549
+ show({
550
+ phase: 'failed',
551
+ market,
552
+ release,
553
+ message: error?.message ?? 'The update failed.',
554
+ retry: 'install',
555
+ force: code === 'RELEASE_TOO_FRESH' || code === 'VERSION_UNCHANGED',
556
+ });
557
+ }
558
+ }
559
+
560
+ async function restart(current) {
561
+ setView({ ...current, restarting: true });
562
+ try {
563
+ const result = await marketRequest(current.market.restart, {
564
+ method: 'POST',
565
+ headers: { 'content-type': 'application/json' },
566
+ body: '{}',
567
+ }, life);
568
+ if (!result.ok) throw new Error(marketFailure(result, 'DSH could not restart'));
569
+ show({ ...current, restarting: false, restartSent: true });
570
+ } catch (error) {
571
+ show({
572
+ phase: 'failed',
573
+ market: current.market,
574
+ release: current.release,
575
+ message: error?.message ?? 'DSH could not restart.',
576
+ retry: 'check',
577
+ force: false,
578
+ });
579
+ }
580
+ }
581
+
582
+ React.useEffect(() => {
583
+ check(false);
584
+ return () => {
585
+ life.cancelled = true;
586
+ if (life.timer !== null) clearTimeout(life.timer);
587
+ if (life.controller !== null) life.controller.abort();
588
+ };
589
+ }, []);
590
+
591
+ const busy = view.phase === 'checking' || view.phase === 'updating' || view.restarting === true;
592
+ const button = (text, onClick, disabled = false, primary = false) => React.createElement('button', {
593
+ type: 'button',
594
+ disabled,
595
+ onClick,
596
+ style: primary ? primaryButtonStyle : secondaryButtonStyle,
597
+ }, text);
598
+ const code = (text) => React.createElement('code', { style: commandStyle }, text);
599
+ const checkAgain = button(t({ en: 'Check again', zh: '重新检查' }), () => check(true));
600
+ const versions = (release) => t({
601
+ en: `Installed v${release.installedVersion} · latest v${release.latestVersion}`,
602
+ zh: `已安装 v${release.installedVersion} · 最新 v${release.latestVersion}`,
603
+ });
604
+
605
+ let versionLine = null;
606
+ const feedback = [];
607
+ let actions = null;
608
+
609
+ if (view.phase === 'checking') {
610
+ versionLine = t({ en: 'Checking…', zh: '正在检查…' });
611
+ } else if (view.phase === 'unavailable') {
612
+ feedback.push(t({
613
+ en: 'DSH Market is not available on this host, so Nerd Mode cannot check for updates. Run this command in a terminal:',
614
+ zh: '此主机上没有 DSH Market,因此 Nerd Mode 无法检查更新。请在终端运行:',
615
+ }));
616
+ feedback.push(code(manualCommand(null)));
617
+ } else if (view.phase === 'failed') {
618
+ if (view.release !== null) versionLine = versions(view.release);
619
+ feedback.push(t({ en: 'The update did not finish: ', zh: '更新未完成:' }));
620
+ feedback.push(view.message);
621
+ feedback.push(t({ en: ' Install it by hand with:', zh: ' 可手动安装:' }));
622
+ feedback.push(code(manualCommand(view.market?.profile ?? null)));
623
+ actions = [button(t({ en: 'Try again', zh: '重试' }), () => {
624
+ if (view.retry === 'install') install(view.market, view.release, view.force === true);
625
+ else check(true);
626
+ })];
627
+ } else if (view.phase === 'installed') {
628
+ versionLine = t({ en: `Installed v${view.version}`, zh: `已安装 v${view.version}` });
629
+ feedback.push(view.restartSent
630
+ ? t({ en: `DSH is restarting to load v${view.version}.`, zh: `DSH 正在重启以加载 v${view.version}。` })
631
+ : t({ en: `Restart DSH to use v${view.version}.`, zh: `重启 DSH 以使用 v${view.version}。` }));
632
+ actions = view.restartNeeded && view.market.restart !== null && view.restartSent !== true
633
+ ? [button(
634
+ t({ en: view.restarting ? 'Restarting…' : 'Restart now', zh: view.restarting ? '正在重启…' : '立即重启' }),
635
+ () => restart(view),
636
+ view.restarting === true,
637
+ true,
638
+ )]
639
+ : [checkAgain];
640
+ } else if (view.release.source === 'linked') {
641
+ versionLine = t({ en: `Local v${view.release.installedVersion}`, zh: `本地 v${view.release.installedVersion}` });
642
+ actions = [button(t({ en: 'About local installs', zh: '关于本地安装' }), () => {
643
+ setView({ ...view, linkedNote: view.linkedNote !== true });
644
+ })];
645
+ if (view.linkedNote === true) {
646
+ feedback.push(t({ en: 'This copy is linked to a folder. Update it with git, not npm.', zh: '此副本链接到本地文件夹。请用 git 更新,不要用 npm。' }));
647
+ }
648
+ } else if (view.phase === 'updating') {
649
+ versionLine = versions(view.release);
650
+ feedback.push(t({ en: `Update in progress (${view.progress}).`, zh: `更新进行中(${view.progress})。` }));
651
+ actions = [button(t({ en: 'Updating…', zh: '正在更新…' }), () => {}, true)];
652
+ } else if (view.release.updateAvailable === true) {
653
+ versionLine = versions(view.release);
654
+ if (view.confirming === true) {
655
+ feedback.push(t({
656
+ en: `Install v${view.release.latestVersion} and restart DSH afterwards?`,
657
+ zh: `安装 v${view.release.latestVersion} 并在之后重启 DSH?`,
658
+ }));
659
+ actions = [
660
+ button(t({ en: 'Cancel', zh: '取消' }), () => setView({ ...view, confirming: false })),
661
+ button(t({ en: 'Update', zh: '更新' }), () => install(view.market, view.release, false), false, true),
662
+ ];
663
+ } else {
664
+ actions = [button(
665
+ t({ en: `Update to v${view.release.latestVersion}`, zh: `更新到 v${view.release.latestVersion}` }),
666
+ () => setView({ ...view, confirming: true }),
667
+ false,
668
+ true,
669
+ )];
670
+ }
671
+ } else if (compareVersions(view.release.installedVersion, view.release.latestVersion) > 0) {
672
+ versionLine = t({
673
+ en: `Local v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
674
+ zh: `本地 v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
675
+ });
676
+ feedback.push(t({ en: 'This copy is newer than npm. No update.', zh: '此副本比 npm 上的版本更新,无需更新。' }));
677
+ actions = [checkAgain];
678
+ } else {
679
+ versionLine = versions(view.release);
680
+ actions = [checkAgain];
681
+ }
682
+
683
+ return React.createElement(
684
+ 'div',
685
+ { 'data-settings-group': 'about', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
686
+ React.createElement(
687
+ 'div',
688
+ { style: groupStyle },
689
+ React.createElement(
690
+ 'div',
691
+ { style: { minWidth: 0 } },
692
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'About', zh: '关于' })),
693
+ versionLine === null ? null : React.createElement('p', { style: groupDescriptionStyle }, versionLine),
694
+ ),
695
+ actions === null ? null : React.createElement(
696
+ 'div',
697
+ { style: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
698
+ ...actions,
699
+ ),
700
+ ),
701
+ React.createElement('p', {
702
+ role: view.phase === 'failed' ? 'alert' : 'status',
703
+ 'aria-busy': busy,
704
+ style: feedbackStyle,
705
+ }, ...feedback),
336
706
  );
337
707
  }
338
708
 
709
+ const settingsColumnStyle = {
710
+ boxSizing: 'border-box',
711
+ color: 'var(--dsw-alias-label-primary)',
712
+ display: 'flex',
713
+ flexDirection: 'column',
714
+ fontFamily: 'var(--dsw-font-family, inherit)',
715
+ gap: 16,
716
+ maxWidth: 720,
717
+ paddingBottom: 24,
718
+ width: '100%',
719
+ };
720
+
339
721
  const groupStyle = {
340
722
  alignItems: 'center',
341
723
  background: 'var(--dsw-alias-bg-layer-1)',
@@ -408,6 +790,16 @@ window.__ModuleLoader__.load({
408
790
  lineHeight: '20px',
409
791
  margin: 0,
410
792
  };
793
+ const commandStyle = {
794
+ background: 'var(--dsw-alias-bg-layer-2)',
795
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
796
+ display: 'inline-block',
797
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
798
+ fontSize: 12,
799
+ lineHeight: '18px',
800
+ padding: '2px 6px',
801
+ wordBreak: 'break-all',
802
+ };
411
803
 
412
804
  function switchStyle(active, disabled) {
413
805
  const accent = 'var(--dsw-alias-state-business-primary)';
@@ -1164,6 +1556,24 @@ window.__ModuleLoader__.load({
1164
1556
  return model;
1165
1557
  }
1166
1558
 
1559
+ // The page fills the dock body so the picker and course name stay in view
1560
+ // while only the lecture rows scroll. The scrollport owns the side padding,
1561
+ // so its bar sits at the page edge rather than inside the text.
1562
+ const lecturesPanelStyle = { ...panelStyle, height: '100%', padding: '24px 0 0' };
1563
+ const lecturesHeaderStyle = { ...panelHeaderStyle, padding: '0 16px' };
1564
+ const lecturesMessageStyle = { ...panelMutedStyle, padding: '0 16px' };
1565
+ const lecturesHeadingStyle = { ...panelHeadingStyle, padding: '0 16px' };
1566
+ const lecturesSectionStyle = { ...panelSectionStyle, flex: 1, minHeight: 0 };
1567
+ const lecturesScrollStyle = {
1568
+ display: 'flex',
1569
+ flex: 1,
1570
+ flexDirection: 'column',
1571
+ gap: 10,
1572
+ minHeight: 0,
1573
+ overflowY: 'auto',
1574
+ padding: '0 16px 40px',
1575
+ };
1576
+
1167
1577
  // The rows and the fold are the only elements the host cannot style for us.
1168
1578
  const LECTURES_CSS = '.lectures-entry:focus-visible,.lectures-fold:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary, currentColor);outline-offset:2px}';
1169
1579
  const lectureRowStyle = {
@@ -1241,14 +1651,14 @@ window.__ModuleLoader__.load({
1241
1651
  function lectureTopic(group, open, onToggle, onOpen) {
1242
1652
  const main = group.entries.filter((entry) => entry.mark !== 'archived');
1243
1653
  const archived = group.entries.filter((entry) => entry.mark === 'archived');
1244
- const nodes = [React.createElement('h2', { key: 'heading', style: panelHeadingStyle }, group.topic)];
1654
+ const rows = [];
1245
1655
  if (group.entries.length === 0) {
1246
- nodes.push(React.createElement('p', { key: 'empty', style: panelMutedStyle }, 'No lectures yet.'));
1656
+ rows.push(React.createElement('p', { key: 'empty', style: panelMutedStyle }, 'No lectures yet.'));
1247
1657
  } else if (main.length > 0) {
1248
- nodes.push(React.createElement('ul', { key: 'main', style: panelListStyle }, ...main.map((entry) => lectureRow(entry, onOpen))));
1658
+ rows.push(React.createElement('ul', { key: 'main', style: panelListStyle }, ...main.map((entry) => lectureRow(entry, onOpen))));
1249
1659
  }
1250
1660
  if (archived.length > 0) {
1251
- nodes.push(React.createElement('button', {
1661
+ rows.push(React.createElement('button', {
1252
1662
  key: 'fold',
1253
1663
  type: 'button',
1254
1664
  className: 'lectures-fold',
@@ -1257,10 +1667,13 @@ window.__ModuleLoader__.load({
1257
1667
  style: lectureFoldStyle,
1258
1668
  }, `Archived lectures (${archived.length})`));
1259
1669
  if (open) {
1260
- nodes.push(React.createElement('ul', { key: 'archive', style: panelListStyle }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1670
+ rows.push(React.createElement('ul', { key: 'archive', style: panelListStyle }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1261
1671
  }
1262
1672
  }
1263
- return React.createElement('section', { key: group.topic, style: panelSectionStyle }, ...nodes);
1673
+ // The Course key remounts the section on a switch, so its scrollport starts at the top.
1674
+ return React.createElement('section', { key: group.topic, style: lecturesSectionStyle },
1675
+ React.createElement('h2', { key: 'heading', style: lecturesHeadingStyle }, group.topic),
1676
+ React.createElement('div', { style: lecturesScrollStyle }, ...rows));
1264
1677
  }
1265
1678
 
1266
1679
  function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
@@ -1288,22 +1701,21 @@ window.__ModuleLoader__.load({
1288
1701
  setSelected(course);
1289
1702
  };
1290
1703
 
1291
- const open = (entry) => tab.actions.openResource(
1292
- sessionFileAddress(sessionId, entry.path),
1293
- { preferNewPane: true },
1294
- );
1704
+ // No placement option: the preview opens as a tab in this pane and the
1705
+ // host focuses it when that lecture is already open.
1706
+ const open = (entry) => tab.actions.openResource(sessionFileAddress(sessionId, entry.path));
1295
1707
 
1296
1708
  let nodes;
1297
1709
  if (content.status === 'loading') {
1298
- nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: panelMutedStyle }, 'Loading the lectures…')];
1710
+ nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: lecturesMessageStyle }, 'Loading the lectures…')];
1299
1711
  } else if (content.error === null && !content.rootFound) {
1300
- nodes = [React.createElement('p', { style: panelMutedStyle }, `No Course in ${TRACKER_ROOT}/. Run /study-plus to create one.`)];
1712
+ nodes = [React.createElement('p', { style: lecturesMessageStyle }, `No Course in ${TRACKER_ROOT}/. Run /study-plus to create one.`)];
1301
1713
  } else {
1302
1714
  nodes = content.error === null
1303
1715
  ? []
1304
- : [React.createElement('p', { role: 'alert', style: panelMutedStyle }, `The lecture library could not be read: ${content.error}.`)];
1716
+ : [React.createElement('p', { role: 'alert', style: lecturesMessageStyle }, `The lecture library could not be read: ${content.error}.`)];
1305
1717
  const picker = topicPicker(content.courses, content.course ?? selected, choose);
1306
- if (picker !== null) nodes.push(React.createElement('div', { style: panelHeaderStyle }, picker));
1718
+ if (picker !== null) nodes.push(React.createElement('div', { style: lecturesHeaderStyle }, picker));
1307
1719
  if (content.course !== null) {
1308
1720
  nodes.push(lectureTopic(
1309
1721
  { topic: content.course, entries: content.entries },
@@ -1316,7 +1728,7 @@ window.__ModuleLoader__.load({
1316
1728
  return React.createElement('div', {
1317
1729
  role: 'region',
1318
1730
  'aria-label': LECTURES_LABEL,
1319
- style: panelStyle,
1731
+ style: lecturesPanelStyle,
1320
1732
  }, React.createElement('style', null, LECTURES_CSS), ...nodes);
1321
1733
  }
1322
1734
 
@@ -1349,17 +1761,20 @@ window.__ModuleLoader__.load({
1349
1761
  }),
1350
1762
  }, NerdModeToggle),
1351
1763
  );
1352
- ctx.slots.inject(SETTINGS_SLOT, () =>
1353
- ctx.slots.register({
1354
- name: SETTINGS_SLOT,
1355
- id: 'dsh-nerd-mode',
1356
- order: 30,
1357
- label: LABEL,
1358
- }, ({ close }) => React.createElement(NerdModeSettingsEntry, {
1359
- close,
1360
- configForms: ctx.configForms,
1361
- })),
1362
- );
1764
+ ctx.inject(['locale'], (localeCtx) => {
1765
+ ctx.slots.inject(SETTINGS_SLOT, () =>
1766
+ ctx.slots.register({
1767
+ name: SETTINGS_SLOT,
1768
+ id: 'dsh-nerd-mode',
1769
+ order: 30,
1770
+ label: LABEL,
1771
+ }, ({ close }) => React.createElement(NerdModeSettingsEntry, {
1772
+ close,
1773
+ configForms: ctx.configForms,
1774
+ locale: localeCtx.locale,
1775
+ })),
1776
+ );
1777
+ });
1363
1778
  ctx.slots.inject(VIEW_SLOT, () =>
1364
1779
  ctx.slots.register({
1365
1780
  name: VIEW_SLOT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-nerd-mode",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "A study switch and study skills for DeepSeek Harness.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: study
3
- description: Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output.
3
+ description: "[v0.4.6] Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output."
4
4
  argument-hint: "<concept or task>"
5
5
  ---
6
6
  # Study
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: study-plus
3
- description: Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router.
3
+ description: "[v0.4.6] Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router."
4
4
  argument-hint: "<course-slug>, or empty to continue the current Course"
5
5
  ---
6
6
  # Study Plus
@@ -52,15 +52,15 @@ For a new graph:
52
52
  2. Make a concept table in prerequisite order. Each row has a one-line definition and prerequisite numbers. Keep prerequisite order separate from textbook reading order.
53
53
  3. Use the source's book and chapter hierarchy when present. Otherwise, propose logical chapter groupings.
54
54
  4. Show the full graph and chapter layout in the reply before the approval question, as a Markdown table. Keep the question text short, because it renders as one paragraph: ask whether to approve, edit, or rebuild the graph shown above, and state that approval starts draft generation for every concept, including any concept the learner then skips, and that drafts are not teaching. Do not put the table in the question text. Do not add another approval step.
55
- 5. After approval, run calibration as one `question` call with up to three items: what the learner wants to be able to do after this Course, what they have already read, studied, or used in this area in their own words, and which concepts in the approved graph they know well enough to skip, as a multi-select list. Items 1 and 2 are free text. Ask the goal and background items only when nothing already answers them: not the current request, not the opening question, not the supplied material. Reuse an opening-question answer verbatim. Derive the level. Then write `graph.md`, create `state.md` with every concept `new` and undated except the concepts selected to skip, which are `skipped`, create an empty `log.md`, and generate one draft per concept (section 3). Do not create `plan.md`.
55
+ 5. After approval, run calibration as one question-tool call with up to three items: what the learner wants to be able to do after this Course, what they have already read, studied, or used in this area in their own words, and which concepts in the approved graph they know well enough to skip, as a multi-select list. Items 1 and 2 are free text. Ask the goal and background items only when nothing already answers them: not the current request, not the opening question, not the supplied material. Reuse an opening-question answer verbatim. Derive the level. Then write `graph.md`, create `state.md` with every concept `new` and undated except the concepts selected to skip, which are `skipped`, create an empty `log.md`, and generate one draft per concept (section 3). Do not create `plan.md`.
56
56
 
57
57
  Calibration writes `Goal`, `Background`, and `Teaching level` to `graph.md`. Derive the level from the two answers:
58
58
 
59
- | Answers | Level |
60
- |---|---|
61
- | Most prerequisites are unknown, or the goal is to learn the topic from zero | `beginner` |
59
+ | Answers | Level |
60
+ | ------------------------------------------------------------------------------------------ | ------------ |
61
+ | Most prerequisites are unknown, or the goal is to learn the topic from zero | `beginner` |
62
62
  | Some prerequisites are known, and the goal is to understand the principles or pass an exam | `standard` |
63
- | Most prerequisites are known, and the goal is review, reference, or use in a project | `advanced` |
63
+ | Most prerequisites are known, and the goal is review, reference, or use in a project | `advanced` |
64
64
 
65
65
  When the two answers clearly conflict, you may choose an adjacent level and state the reason in one line. When the learner declines or gives no usable answer, write `Teaching level: standard` and omit the `Goal` and `Background` lines. Each selected concept becomes `skipped`, with no date and no passes.
66
66
 
@@ -136,7 +136,7 @@ If the row was already `in_process`, say that the last session stopped before th
136
136
 
137
137
  **Ask unseen questions.** Use the explicit count or type preference in the current `/study-plus` request when present. Otherwise, use the profile defaults in the top-level Nerd Mode instructions. Plan the configured number of unseen question slots, from 0 to 10. A hint, re-ask, or similar question stays in its current slot and does not add a slot. For each slot, write down the expected answer and criteria for a good answer. Keep both private. A draft never contains them. The criteria test the conclusion and whether each step taken is valid, not the route you had in mind. Ask one question at a time, then wait. One call asks one question. Outside `study-plus`, ignore these preferences.
138
138
 
139
- For `short-answer`, ask the question in the conversation. For `multiple-choice`, ask it as one `question` call with four options and exactly one correct answer; choosing it is sufficient. Give the labels alone, with no description, and shuffle the four positions for each slot. A host without a `question` tool gets the same four options as a numbered text list. A typed answer that names or repeats one option counts as choosing it. Any other typed answer is graded like a short answer. For `mixed`, choose short-answer or multiple-choice independently at random for each planned slot. Do not add question-type details to the session log. When the configured count is zero, skip unseen questions. A passed Teach-back alone is sufficient to pass. With one or more slots, require a passed Teach-back and `ceil(2 × slot count / 3)` correct slots.
139
+ For `short-answer`, ask the question in the conversation. For `multiple-choice`, ask it as one question-tool call with four options and exactly one correct answer; choosing it is sufficient. Give the labels alone, with no description, and shuffle the four positions for each slot. A host without a question tool gets the same four options as a numbered text list. A typed answer that names or repeats one option counts as choosing it. Any other typed answer is graded like a short answer. For `mixed`, choose short-answer or multiple-choice independently at random for each planned slot. Do not add question-type details to the session log. When the configured count is zero, skip unseen questions. A passed Teach-back alone is sufficient to pass. With one or more slots, require a passed Teach-back and `ceil(2 × slot count / 3)` correct slots.
140
140
 
141
141
  A question exists to find the gap, not to stop the session. Nothing in this step blocks the learner.
142
142
 
@@ -13,8 +13,8 @@
13
13
  if (!panels.length) return;
14
14
 
15
15
  var L = book.lang === 'en'
16
- ? { search: 'Search the book', toc: 'Contents', none: 'No match', hits: ' hits', chapter: 'Chapter', section: 'Section', sections: 'sections', menu: 'Chapters', draft: 'Draft' }
17
- : { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第', section: '第', sections: '节', menu: '章节', draft: '草稿' };
16
+ ? { search: 'Search the book', toc: 'Contents', none: 'No match', hits: ' hits', chapter: 'Chapter', sections: 'sections', menu: 'Chapters', draft: 'Draft' }
17
+ : { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第', sections: '节', menu: '章节', draft: '草稿' };
18
18
 
19
19
  var layout = document.querySelector('.am-doc-layout');
20
20
  var body = document.querySelector('.am-doc-body');
@@ -33,10 +33,8 @@
33
33
  : L.chapter + chapter.n + '章 ' + chapter.name;
34
34
  }
35
35
 
36
- function sectionLabel(unit) {
37
- return book.lang === 'en'
38
- ? L.section + ' ' + unit.section
39
- : L.section + unit.section + '节';
36
+ function sectionNumber(unit) {
37
+ return unit.chapter + '.' + unit.section;
40
38
  }
41
39
 
42
40
  function chapterCount(chapter) {
@@ -46,9 +44,7 @@
46
44
  }
47
45
 
48
46
  function sectionTitle(unit) {
49
- return book.lang === 'en'
50
- ? L.section + ' ' + unit.section + '. ' + unit.title
51
- : L.section + unit.section + '节 ' + unit.title;
47
+ return sectionNumber(unit) + ' ' + unit.title;
52
48
  }
53
49
 
54
50
  var style = document.createElement('style');
@@ -70,6 +66,8 @@
70
66
  '.am-doc-body .am-md h3 { font-size: 18px; font-weight: 650; margin-top: 28px; }',
71
67
  '.am-doc-body .am-md h3:first-child { margin-top: 0; }',
72
68
  '.am-doc-body .am-md h4 { font-size: 16px; font-weight: 600; margin-top: 20px; }',
69
+ // am's overflow-x:auto makes overflow-y auto too; a 1px overshoot draws scrollbar arrows on Windows.
70
+ '.am-math-block { overflow-y: hidden; }',
73
71
  '.bk-context { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 8px; font-size: 13px; }',
74
72
  '.bk-context-chapter { color: var(--ink-2); font-weight: 600; }',
75
73
  '.bk-context-section { color: var(--ink-3); white-space: nowrap; }',
@@ -151,7 +149,7 @@
151
149
  chapter.textContent = chapterTitle(g.chapter);
152
150
  var section = document.createElement('span');
153
151
  section.className = 'bk-context-section';
154
- section.innerHTML = esc(sectionLabel(u)) + draftTag(u);
152
+ section.innerHTML = esc(sectionNumber(u)) + draftTag(u);
155
153
  context.appendChild(chapter);
156
154
  context.appendChild(section);
157
155
  var heading = panel.querySelector('.am-panel-head');
@@ -82,9 +82,13 @@ function build() {
82
82
  ...(book.goal ? [`subtitle: ${JSON.stringify(book.goal)}`] : []),
83
83
  '---',
84
84
  '',
85
- book.units.map((unit) => `## ${unit.section}. ${unit.title}\n\n${unit.body}`).join('\n\n'),
85
+ book.units.map((unit) => `## ${unit.chapter}.${unit.section} ${unit.title}\n\n${unit.body}`).join('\n\n'),
86
86
  '',
87
- ].join('\n');
87
+ ].join('\n')
88
+ // am reads $ and $$ math only: a LaTeX display delimiter on its own line
89
+ // would reach the page as a literal bracket, so turn it into a $$ block.
90
+ .replace(/^ {0,3}\\\[\s*$/gm, () => '$$')
91
+ .replace(/^ {0,3}\\\]\s*$/gm, () => '$$');
88
92
  const outPath = join(dir, 'book.html');
89
93
  const backupPath = join(dir, 'book.html.bak');
90
94
  const guardPath = (path, what) => {
@@ -123,7 +127,9 @@ function build() {
123
127
  units: book.units.map(({ concept, title, chapter, section, index, status }) => ({ concept, title, chapter, section, index, status })),
124
128
  };
125
129
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
126
- const client = readFileSync(CLIENT, 'utf8');
130
+ // HTML parsing turns CRLF into LF before the browser checks the CSP hash,
131
+ // so a Windows checkout would otherwise be blocked as the wrong hash.
132
+ const client = readFileSync(CLIENT, 'utf8').replace(/\r\n?/g, '\n');
127
133
  const html = readFileSync(tmpPath, 'utf8');
128
134
  const scriptText = `\n${client}\n`;
129
135
  const hash = createHash('sha256').update(scriptText, 'utf8').digest('base64');