dsh-novel-write-engine 0.2.7 → 0.2.9

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
@@ -5,9 +5,12 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
+ let react_dom = require("react-dom");
8
9
  // src/constants.ts
9
10
  const PANEL_ID = "novel-workshop";
10
11
  const PANEL_ROUTE = "/api/novel-write-engine/panel";
12
+ /** User-visible panel/tutorial version; keep in sync with package.json. */
13
+ const PANEL_VERSION = "0.2.9";
11
14
  // src/next-reasons.ts
12
15
  /**
13
16
  * Chinese one-line explanation for the tool each engine state recommends.
@@ -37,6 +40,9 @@ window.__ModuleLoader__.load({
37
40
  novel_rollback: "回退到更早阶段重建当前章。",
38
41
  novel_amend: "按明确的修复理由修正一份状态文档。",
39
42
  novel_audit: "先查看事务日志,确认并恢复损坏的历史。",
43
+ novel_restore: "按备份清单恢复损坏的状态文档或当前章。",
44
+ novel_state: "查看状态文档、大纲、世界观或历史原文。",
45
+ novel_pipeline: "按当前阶段领取下一步工具与参数。",
40
46
  novel_export: "导出成书或书级提案。",
41
47
  novel_diff: "对比当前正文与上一版或给定文本。",
42
48
  novel_word_count: "按引擎字数口径核对正文长度。"
@@ -50,9 +56,9 @@ window.__ModuleLoader__.load({
50
56
  P4: "评审",
51
57
  P5: "修订",
52
58
  P6: "一致性",
53
- P7: "终审",
54
- P8: "归档",
55
- P9: "完成"
59
+ P7: "状态同步",
60
+ P8: "终审",
61
+ P9: "归档"
56
62
  };
57
63
  const STAGE_LABELS = {
58
64
  idle: "待开始",
@@ -115,6 +121,7 @@ window.__ModuleLoader__.load({
115
121
  damaged: false,
116
122
  stageLabel: "读取中",
117
123
  finished: false,
124
+ chapter: 0,
118
125
  revisionLabel: "",
119
126
  phases: [],
120
127
  rows: [],
@@ -152,7 +159,7 @@ window.__ModuleLoader__.load({
152
159
  },
153
160
  quality: {
154
161
  averageScore: 0,
155
- lastTenAverage: 0,
162
+ lastTenAverage: null,
156
163
  bookMedian: 0,
157
164
  bookMin: 0,
158
165
  bookMax: 0,
@@ -304,15 +311,15 @@ window.__ModuleLoader__.load({
304
311
  hint: item.hint ?? ""
305
312
  }));
306
313
  const rows = chapterRows.map((row) => {
307
- const scoreValue = Number.isFinite(row.score) ? Math.max(0, Math.min(150, row.score)) : 0;
314
+ const scoreValue = row.score === null ? null : Math.max(0, Math.min(150, Number.isFinite(row.score) ? row.score : 0));
308
315
  return {
309
316
  num: String(row.num),
310
317
  title: row.title,
311
318
  words: row.wordCount.toLocaleString("zh-CN"),
312
319
  wordCount: row.wordCount,
313
- score: `${scoreValue}/150`,
320
+ score: scoreValue === null ? "—" : `${scoreValue}/150`,
314
321
  scoreValue,
315
- scorePercent: Math.round(scoreValue / 150 * 100),
322
+ scorePercent: scoreValue === null ? null : Math.round(scoreValue / 150 * 100),
316
323
  badge: row.status === "passed" ? "已归档" : row.status === "blocked" ? "阻塞" : "草稿",
317
324
  tone: row.status === "passed" ? "ok" : row.status === "blocked" ? "bad" : "muted",
318
325
  details: row.details ?? "",
@@ -328,12 +335,14 @@ window.__ModuleLoader__.load({
328
335
  archivedAt: row.archivedAt ?? null
329
336
  };
330
337
  });
331
- const scored = rows.filter((row) => row.tone === "ok");
332
- const rowAverage = scored.length === 0 ? 0 : scored.reduce((sum, row) => sum + row.scoreValue, 0) / scored.length;
338
+ const scored = rows.filter((row) => row.tone === "ok" && row.scoreValue !== null);
339
+ const rowAverage = scored.length === 0 ? 0 : scored.reduce((sum, row) => sum + (row.scoreValue ?? 0), 0) / scored.length;
333
340
  const average = summary.averageScore !== void 0 && Number.isFinite(summary.averageScore) ? summary.averageScore : rowAverage;
334
341
  const next = summary.next;
335
- const finished = summary.finished === true;
342
+ const progress = summary.progress;
343
+ const finished = progress?.finished ?? summary.finished === true;
336
344
  const doneLabel = summary.stage === "done" ? finished ? "全书完成" : "本章已归档" : stageLabel(summary.stage);
345
+ const progressLine = progress?.label ?? (summary.stage === "done" ? finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章` : `第 ${summary.chapter} 章`);
337
346
  const selected = base.projects.find((project) => project.selected);
338
347
  return {
339
348
  ...base,
@@ -343,9 +352,10 @@ window.__ModuleLoader__.load({
343
352
  damaged: selected?.state === "damaged",
344
353
  resumeText: payload.resume?.text ?? "",
345
354
  volumeNumber: summary.volume,
346
- subtitle: summary.stage === "done" ? `${summary.genre} · ${platformLabel(summary.platform)} · ${finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章`}${volumeLabel(summary)}` : `${summary.genre} · ${platformLabel(summary.platform)} · 第 ${summary.chapter} 章${volumeLabel(summary)}`,
355
+ subtitle: `${summary.genre} · ${platformLabel(summary.platform)} · ${progressLine}${volumeLabel(summary)}`,
347
356
  stageLabel: doneLabel,
348
357
  finished,
358
+ chapter: summary.chapter,
349
359
  revisionLabel: `第 ${summary.bookData?.current.rounds.revise ?? summary.revision} 次修订`,
350
360
  phases: summary.phases.map((phase) => ({
351
361
  ...phase,
@@ -357,6 +367,8 @@ window.__ModuleLoader__.load({
357
367
  stats: {
358
368
  chapters: summary.totalChapters,
359
369
  words: summary.totalWords,
370
+ ...summary.archivedWords === void 0 ? {} : { archivedWords: summary.archivedWords },
371
+ ...summary.draftWords === void 0 ? {} : { draftWords: summary.draftWords },
360
372
  averageScore: Math.round(average * 10) / 10,
361
373
  blocking: blocking.length
362
374
  },
@@ -392,7 +404,7 @@ window.__ModuleLoader__.load({
392
404
  },
393
405
  quality: {
394
406
  averageScore: summary.bookData?.quality.average ?? summary.averageScore ?? average,
395
- lastTenAverage: summary.bookData?.quality.last_ten_average ?? summary.quality?.averageScore ?? average,
407
+ lastTenAverage: summary.bookData?.quality.last_ten_average ?? summary.quality?.last_ten_average ?? null,
396
408
  bookMedian: summary.bookData?.quality.median ?? 0,
397
409
  bookMin: summary.bookData?.quality.min ?? 0,
398
410
  bookMax: summary.bookData?.quality.max ?? 0,
@@ -401,7 +413,7 @@ window.__ModuleLoader__.load({
401
413
  trend: summary.bookData?.quality.trend ?? summary.quality?.trend ?? null,
402
414
  trendText: (summary.bookData?.quality.trend ?? summary.quality?.trend) === null || (summary.bookData?.quality.trend ?? summary.quality?.trend) === void 0 ? summary.quality?.trend_basis === "insufficient_samples" || summary.bookData?.quality.trend_basis === "insufficient_samples" ? "样本不足" : summary.quality?.trend_basis === "insufficient_history" || summary.bookData?.quality.trend_basis === "insufficient_history" ? "历史不足" : "—" : `${(summary.bookData?.quality.trend ?? summary.quality.trend) >= 0 ? "+" : ""}${summary.bookData?.quality.trend ?? summary.quality.trend}`,
403
415
  samples: summary.bookData?.quality.samples ?? summary.quality?.samples ?? 0,
404
- attempts: Math.max(summary.bookData?.quality.attempts ?? summary.quality?.attempts ?? 0, summary.bookData?.quality.samples ?? summary.quality?.samples ?? 0)
416
+ attempts: summary.bookData?.quality.attempts ?? summary.quality?.attempts ?? 0
405
417
  },
406
418
  health: {
407
419
  ok: summary.health?.ok ?? blocking.length === 0,
@@ -428,12 +440,20 @@ window.__ModuleLoader__.load({
428
440
  };
429
441
  }
430
442
  /** Fetch the panel payload; failures become a plain message, never a throw. */
431
- async function fetchPanel(fetcher, path, signal) {
443
+ async function fetchPanel(fetcher, path, signal, etag) {
432
444
  try {
433
445
  const response = await fetcher(path, {
434
- headers: { accept: "application/json" },
446
+ headers: {
447
+ accept: "application/json",
448
+ ...etag === void 0 ? {} : { "if-none-match": etag }
449
+ },
435
450
  ...signal === void 0 ? {} : { signal }
436
451
  });
452
+ if (response.status === 304) return {
453
+ payload: null,
454
+ error: null,
455
+ notModified: true
456
+ };
437
457
  if (!response.ok) {
438
458
  let detail = `宿主返回 ${response.status}`;
439
459
  try {
@@ -464,7 +484,9 @@ window.__ModuleLoader__.load({
464
484
  --nwe-surface: var(--dsw-alias-bg-layer-1, #171a20);
465
485
  --nwe-raised: var(--dsw-alias-bg-layer-2, #1e222a);
466
486
  --nwe-border: var(--dsw-alias-border-l1, #ffffff14);
467
- --nwe-gold: color-mix(in srgb, #b7862f 82%, var(--nwe-ink));
487
+ --nwe-gold: var(--dsw-alias-label-accent, color-mix(in srgb, var(--nwe-ink) 58%, var(--nwe-raised)));
488
+ --nwe-accent: var(--nwe-gold);
489
+ --nwe-font: var(--dsw-font-family, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif);
468
490
  --nwe-jade: color-mix(in srgb, #4f9f82 68%, var(--nwe-ink));
469
491
  --nwe-rose: color-mix(in srgb, #c96a6a 68%, var(--nwe-ink));
470
492
  position: relative;
@@ -481,7 +503,7 @@ window.__ModuleLoader__.load({
481
503
  margin: 0 auto;
482
504
  padding: clamp(18px, 3.2vw, 38px);
483
505
  color: var(--nwe-ink);
484
- font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
506
+ font-family: var(--nwe-font);
485
507
  line-height: 1.55;
486
508
  }
487
509
  .nwe-root * { box-sizing: border-box; }
@@ -508,7 +530,7 @@ window.__ModuleLoader__.load({
508
530
  }
509
531
  .nwe-eyebrow {
510
532
  color: var(--nwe-gold);
511
- font-size: 10px;
533
+ font-size: 12px;
512
534
  font-weight: 600;
513
535
  letter-spacing: .22em;
514
536
  text-transform: uppercase;
@@ -537,7 +559,7 @@ window.__ModuleLoader__.load({
537
559
  border: 1px solid color-mix(in srgb, var(--nwe-gold) 50%, transparent);
538
560
  border-radius: 999px;
539
561
  color: var(--nwe-gold);
540
- font-size: 11px;
562
+ font-size: 12px;
541
563
  font-weight: 600;
542
564
  }
543
565
  .nwe-stage::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
@@ -561,7 +583,7 @@ window.__ModuleLoader__.load({
561
583
  .nwe-refresh:disabled { cursor: wait; opacity: .55; }
562
584
  .nwe-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; }
563
585
  .nwe-project { display: flex; align-items: center; gap: 6px; min-width: 0; }
564
- .nwe-project-label { color: var(--nwe-muted); font-size: 11px; white-space: nowrap; }
586
+ .nwe-project-label { color: var(--nwe-muted); font-size: 12px; white-space: nowrap; }
565
587
  .nwe-project-select {
566
588
  max-width: 260px;
567
589
  min-height: 36px;
@@ -573,7 +595,7 @@ window.__ModuleLoader__.load({
573
595
  font: inherit;
574
596
  font-size: 12px;
575
597
  }
576
- .nwe-rootline { margin-top: 5px; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; overflow-wrap: anywhere; }
598
+ .nwe-rootline { margin-top: 5px; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
577
599
  .nwe-damaged { margin-top: 10px; padding: 9px 12px; border: 1px solid color-mix(in srgb, var(--nwe-rose) 36%, var(--nwe-border)); border-radius: 10px; color: var(--nwe-rose); background: color-mix(in srgb, var(--nwe-rose) 7%, transparent); font-size: 12px; }
578
600
  .nwe-help {
579
601
  display: inline-flex;
@@ -602,7 +624,7 @@ window.__ModuleLoader__.load({
602
624
  align-items: center;
603
625
  justify-content: center;
604
626
  padding: clamp(12px, 3.5vw, 42px);
605
- background: color-mix(in srgb, #080a0d 76%, transparent);
627
+ background: color-mix(in srgb, #000 76%, transparent);
606
628
  backdrop-filter: blur(7px);
607
629
  }
608
630
  .nwe-modal {
@@ -666,27 +688,49 @@ window.__ModuleLoader__.load({
666
688
  .nwe-tutorial-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
667
689
  .nwe-tutorial-card { padding: 11px 12px; border: 1px solid var(--nwe-border); border-radius: 11px; background: color-mix(in srgb, var(--nwe-raised) 52%, transparent); }
668
690
  .nwe-tutorial-card strong { display: block; color: var(--nwe-gold); font-size: 12px; }
669
- .nwe-tutorial-card span { display: block; margin-top: 4px; color: var(--nwe-muted); font-size: 11px; line-height: 1.65; }
691
+ .nwe-tutorial-card span { display: block; margin-top: 4px; color: var(--nwe-muted); font-size: 12px; line-height: 1.65; }
670
692
  .nwe-steps { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
671
693
  .nwe-step { padding: 12px 14px; border: 1px solid var(--nwe-border); border-left: 2px solid color-mix(in srgb, var(--nwe-gold) 58%, var(--nwe-border)); border-radius: 11px; background: color-mix(in srgb, var(--nwe-surface) 86%, var(--nwe-raised) 14%); }
672
694
  .nwe-step-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
673
- .nwe-step-no { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; letter-spacing: .06em; }
695
+ .nwe-step-no { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; letter-spacing: .06em; }
674
696
  .nwe-step h4 { margin: 0; font-size: 13px; font-weight: 600; }
675
- .nwe-step-tool { margin-left: auto; padding: 2px 6px; border: 1px solid var(--nwe-border); border-radius: 5px; color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; white-space: nowrap; }
697
+ .nwe-step-tool { margin-left: auto; padding: 2px 6px; border: 1px solid var(--nwe-border); border-radius: 5px; color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: nowrap; }
676
698
  .nwe-step p { margin: 6px 0 0; color: var(--nwe-muted); font-size: 12px; line-height: 1.7; }
677
699
  .nwe-step p strong { color: var(--nwe-ink); font-weight: 600; }
678
- .nwe-tutorial-note { margin: 12px 0 0; padding: 10px 12px; border-radius: 9px; background: color-mix(in srgb, var(--nwe-gold) 8%, transparent); color: var(--nwe-muted); font-size: 11px; line-height: 1.7; }
700
+ .nwe-tutorial-note { margin: 12px 0 0; padding: 10px 12px; border-radius: 9px; background: color-mix(in srgb, var(--nwe-gold) 8%, transparent); color: var(--nwe-muted); font-size: 12px; line-height: 1.7; }
679
701
  .nwe-tutorial-note strong { color: var(--nwe-gold); }
680
702
  .nwe-faq { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
681
703
  .nwe-faq li { padding: 10px 12px; border: 1px solid var(--nwe-border); border-radius: 10px; }
682
704
  .nwe-faq strong { display: block; color: var(--nwe-ink); font-size: 12px; }
683
705
  .nwe-faq p { margin: 4px 0 0; color: var(--nwe-muted); font-size: 12px; line-height: 1.65; }
684
- .nwe-prompt { overflow-x: auto; margin: 0; padding: 12px 14px; border: 1px solid var(--nwe-border); border-radius: 10px; background: color-mix(in srgb, #080a0d 24%, var(--nwe-surface)); color: var(--nwe-ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.75; white-space: pre-wrap; }
706
+ .nwe-copy-block { margin-top: 0; }
707
+ .nwe-copy-row { display: flex; align-items: center; gap: 10px; margin-top: 7px; }
708
+ .nwe-copy-status { color: var(--nwe-muted); font-size: 12px; }
709
+ .nwe-prompt { overflow-x: auto; margin: 0; padding: 12px 14px; border: 1px solid var(--nwe-border); border-radius: 10px; background: color-mix(in srgb, #000 24%, var(--nwe-surface)); color: var(--nwe-ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.75; white-space: pre-wrap; }
685
710
  .nwe-checklist { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
686
711
  .nwe-checklist li { display: flex; gap: 7px; color: var(--nwe-muted); font-size: 12px; line-height: 1.6; }
687
712
  .nwe-checklist li::before { content: "✓"; color: var(--nwe-jade); font-weight: 700; }
688
- .nwe-modal-foot { flex: 0 0 auto; padding: 10px 24px; border-top: 1px solid var(--nwe-border); color: var(--nwe-muted); font-size: 10px; }
713
+ .nwe-modal-foot { flex: 0 0 auto; padding: 10px 24px; border-top: 1px solid var(--nwe-border); color: var(--nwe-muted); font-size: 12px; }
689
714
  .nwe-help-label { white-space: nowrap; }
715
+ .nwe-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 18px 0 0; padding: 4px; border: 1px solid var(--nwe-border); border-radius: 12px; background: color-mix(in srgb, var(--nwe-raised) 65%, transparent); }
716
+ .nwe-tab { padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--nwe-muted); font: inherit; font-size: 12px; cursor: pointer; }
717
+ .nwe-tab.is-active { background: color-mix(in srgb, var(--nwe-gold) 14%, transparent); color: var(--nwe-ink); }
718
+ .nwe-tab:focus-visible { outline: 2px solid var(--nwe-accent); outline-offset: 2px; }
719
+ .nwe-section-list { display: grid; gap: 7px; }
720
+ .nwe-data-row { border: 1px solid var(--nwe-border); border-radius: 9px; background: color-mix(in srgb, var(--nwe-raised) 55%, transparent); }
721
+ .nwe-data-row > summary { cursor: pointer; padding: 9px 11px; font-size: 12px; overflow-wrap: anywhere; }
722
+ .nwe-data-json { margin: 0; padding: 0 11px 11px; color: var(--nwe-muted); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
723
+ .nwe-subsection { margin: 14px 0 0; }
724
+ .nwe-subsection > h3 { margin: 0 0 6px; color: var(--nwe-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; }
725
+ .nwe-kv { display: grid; grid-template-columns: minmax(72px, max-content) minmax(0, 1fr); gap: 4px 12px; margin: 0; padding: 0 11px 11px; font-size: 12px; }
726
+ .nwe-kv dt { color: var(--nwe-muted); }
727
+ .nwe-kv dd { margin: 0; overflow-wrap: anywhere; }
728
+ .nwe-quality-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 6px; color: var(--nwe-muted); font-size: 12px; }
729
+ .nwe-dim { display: grid; grid-template-columns: 72px minmax(0, 1fr) 76px; align-items: center; gap: 9px; font-size: 12px; }
730
+ .nwe-dim > span:last-child { color: var(--nwe-muted); text-align: right; font-variant-numeric: tabular-nums; }
731
+ .nwe-dim .nwe-v6-bar { margin: 0; }
732
+ .nwe-health-flags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
733
+ .nwe-empty { padding: 18px; border: 1px dashed var(--nwe-border); border-radius: 10px; color: var(--nwe-muted); font-size: 12px; }
690
734
  .nwe-stats {
691
735
  display: grid;
692
736
  grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -699,7 +743,7 @@ window.__ModuleLoader__.load({
699
743
  border-radius: 14px;
700
744
  background: color-mix(in srgb, var(--nwe-surface) 88%, var(--nwe-gold) 4%);
701
745
  }
702
- .nwe-stat-label { color: var(--nwe-muted); font-size: 11px; letter-spacing: .04em; }
746
+ .nwe-stat-label { color: var(--nwe-muted); font-size: 12px; letter-spacing: .04em; }
703
747
  .nwe-stat-value {
704
748
  margin-top: 3px;
705
749
  font-size: 22px;
@@ -707,7 +751,7 @@ window.__ModuleLoader__.load({
707
751
  font-weight: 600;
708
752
  letter-spacing: -.02em;
709
753
  }
710
- .nwe-stat-unit { margin-left: 2px; color: var(--nwe-muted); font-size: 11px; font-weight: 400; }
754
+ .nwe-stat-unit { margin-left: 2px; color: var(--nwe-muted); font-size: 12px; font-weight: 400; }
711
755
  .nwe-section { margin-top: 26px; }
712
756
  .nwe-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
713
757
  .nwe-section-title {
@@ -716,7 +760,7 @@ window.__ModuleLoader__.load({
716
760
  font-size: 17px;
717
761
  font-weight: 600;
718
762
  }
719
- .nwe-section-note { color: var(--nwe-muted); font-size: 11px; }
763
+ .nwe-section-note { color: var(--nwe-muted); font-size: 12px; }
720
764
  .nwe-phases {
721
765
  display: grid;
722
766
  grid-template-columns: repeat(9, minmax(0, 1fr));
@@ -750,37 +794,37 @@ window.__ModuleLoader__.load({
750
794
  border-radius: 50%;
751
795
  background: var(--nwe-surface);
752
796
  color: transparent;
753
- font-size: 9px;
797
+ font-size: 12px;
754
798
  font-weight: 700;
755
799
  }
756
800
  .nwe-phase.is-done .nwe-dot { border-color: color-mix(in srgb, var(--nwe-jade) 70%, transparent); background: color-mix(in srgb, var(--nwe-jade) 18%, var(--nwe-surface)); color: var(--nwe-jade); }
757
801
  .nwe-phase.is-active .nwe-dot { border-color: var(--nwe-gold); background: var(--nwe-gold); color: #19150d; box-shadow: 0 0 0 4px color-mix(in srgb, var(--nwe-gold) 16%, transparent); }
758
- .nwe-phase-label { overflow: hidden; max-width: 100%; color: var(--nwe-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
802
+ .nwe-phase-label { overflow: hidden; max-width: 100%; color: var(--nwe-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
759
803
  .nwe-phase.is-active .nwe-phase-label { color: var(--nwe-ink); font-weight: 600; }
760
804
  .nwe-book-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 10px; margin-top: 12px; }
761
805
  .nwe-book-card { padding: 14px 16px; border: 1px solid var(--nwe-border); border-radius: 14px; background: color-mix(in srgb, var(--nwe-surface) 92%, transparent); }
762
806
  .nwe-book-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
763
807
  .nwe-book-title { font-family: "Noto Serif SC", "Songti SC", Georgia, serif; font-size: 14px; font-weight: 600; }
764
- .nwe-book-note { color: var(--nwe-muted); font-size: 10px; }
808
+ .nwe-book-note { color: var(--nwe-muted); font-size: 12px; }
765
809
  .nwe-progress { overflow: hidden; height: 7px; margin-top: 10px; border-radius: 99px; background: var(--nwe-border); }
766
810
  .nwe-progress > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--nwe-jade), var(--nwe-gold)); }
767
- .nwe-progress-legend { display: flex; justify-content: space-between; margin-top: 5px; color: var(--nwe-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
811
+ .nwe-progress-legend { display: flex; justify-content: space-between; margin-top: 5px; color: var(--nwe-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
768
812
  .nwe-volumes { max-height: 190px; overflow-y: auto; overscroll-behavior: contain; padding-right: 3px; }
769
- .nwe-volume { display: flex; justify-content: space-between; gap: 8px; margin-top: 7px; color: var(--nwe-muted); font-size: 11px; }
813
+ .nwe-volume { display: flex; justify-content: space-between; gap: 8px; margin-top: 7px; color: var(--nwe-muted); font-size: 12px; }
770
814
  .nwe-volume strong { overflow: hidden; color: var(--nwe-ink); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
771
815
  .nwe-volume.is-current strong { color: var(--nwe-gold); }
772
- .nwe-copy { margin-top: 8px; padding: 6px 10px; border: 1px solid var(--nwe-border); border-radius: 8px; background: var(--nwe-raised); color: var(--nwe-muted); font: inherit; font-size: 11px; cursor: pointer; }
816
+ .nwe-copy { margin-top: 8px; padding: 6px 10px; border: 1px solid var(--nwe-border); border-radius: 8px; background: var(--nwe-raised); color: var(--nwe-muted); font: inherit; font-size: 12px; cursor: pointer; }
773
817
  .nwe-copy:hover { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); color: var(--nwe-ink); }
774
- .nwe-resume-fallback { margin-top: 6px; padding: 8px 10px; border: 1px solid var(--nwe-border); border-radius: 8px; background: color-mix(in srgb, #080a0d 24%, var(--nwe-surface)); color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; line-height: 1.6; white-space: pre-wrap; }
818
+ .nwe-resume-fallback { margin-top: 6px; padding: 8px 10px; border: 1px solid var(--nwe-border); border-radius: 8px; background: color-mix(in srgb, #000 24%, var(--nwe-surface)); color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
775
819
  .nwe-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
776
- .nwe-chip { padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; color: var(--nwe-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
820
+ .nwe-chip { padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; color: var(--nwe-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
777
821
  .nwe-bars { display: flex; align-items: flex-end; gap: 5px; height: 54px; margin-top: 10px; }
778
822
  .nwe-bar { flex: 1 1 0; min-width: 4px; border-radius: 4px 4px 2px 2px; background: linear-gradient(180deg, var(--nwe-gold), color-mix(in srgb, var(--nwe-gold) 35%, transparent)); }
779
- .nwe-health { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--nwe-muted); font-size: 11px; }
823
+ .nwe-health { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--nwe-muted); font-size: 12px; }
780
824
  .nwe-health.is-bad { color: var(--nwe-rose); }
781
825
  .nwe-activity { display: flex; flex-direction: column; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
782
- .nwe-activity li { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 8px; color: var(--nwe-muted); font-size: 11px; }
783
- .nwe-activity code { color: var(--nwe-ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
826
+ .nwe-activity li { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 8px; color: var(--nwe-muted); font-size: 12px; }
827
+ .nwe-activity code { color: var(--nwe-ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
784
828
  .nwe-next {
785
829
  display: flex;
786
830
  align-items: flex-start;
@@ -793,11 +837,27 @@ window.__ModuleLoader__.load({
793
837
  background: linear-gradient(90deg, color-mix(in srgb, var(--nwe-gold) 9%, transparent), transparent 72%), var(--nwe-surface);
794
838
  }
795
839
  .nwe-next-icon { display: grid; place-items: center; flex: 0 0 auto; width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--nwe-gold) 15%, transparent); color: var(--nwe-gold); }
796
- .nwe-next-kicker { color: var(--nwe-gold); font-size: 10px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; }
840
+ .nwe-next-kicker { color: var(--nwe-gold); font-size: 12px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; }
797
841
  .nwe-next-title { margin-top: 2px; font-size: 15px; font-weight: 600; }
798
842
  .nwe-next-detail { margin-top: 2px; color: var(--nwe-muted); font-size: 12px; }
799
- .nwe-next-tool { margin-left: auto; align-self: center; flex: 0 0 auto; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
843
+ .nwe-next-tool { margin-left: auto; align-self: center; flex: 0 0 auto; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
800
844
  .nwe-chapters { display: flex; flex-direction: column; gap: 9px; margin: 12px 0 0; padding: 0; list-style: none; }
845
+ .nwe-chapter-toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; margin: 12px 0 0; padding: 10px 12px; border: 1px solid var(--nwe-border); border-radius: 10px; background: color-mix(in srgb, var(--nwe-raised) 55%, transparent); }
846
+ .nwe-chapter-toolbar label { display: grid; gap: 3px; color: var(--nwe-muted); font-size: 12px; }
847
+ .nwe-chapter-toolbar input, .nwe-chapter-toolbar select { min-height: 30px; padding: 4px 7px; border: 1px solid var(--nwe-border); border-radius: 7px; color: var(--nwe-ink); background: var(--nwe-surface); font: inherit; font-size: 12px; }
848
+ .nwe-chapter-toolbar input { min-width: 150px; }
849
+ .nwe-section-search { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; color: var(--nwe-muted); font-size: 12px; }
850
+ .nwe-section-search input { min-height: 30px; min-width: 180px; padding: 4px 8px; border: 1px solid var(--nwe-border); border-radius: 7px; color: var(--nwe-ink); background: var(--nwe-surface); font: inherit; font-size: 12px; }
851
+ .nwe-table-wrap { overflow-x: auto; margin-top: 10px; border: 1px solid var(--nwe-border); border-radius: 10px; }
852
+ .nwe-chapter-detail { margin-top: 12px; padding: 12px; border: 1px solid var(--nwe-border); border-radius: 10px; background: color-mix(in srgb, var(--nwe-raised) 45%, transparent); }
853
+ .nwe-detail-text { max-height: 360px; overflow: auto; margin: 8px 0 0; padding: 10px; border-radius: 8px; background: var(--nwe-surface); color: var(--nwe-ink); font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
854
+ .nwe-chapter-table { width: 100%; min-width: 640px; border-collapse: collapse; font-size: 12px; }
855
+ .nwe-chapter-table th, .nwe-chapter-table td { padding: 8px 10px; border-bottom: 1px solid var(--nwe-border); text-align: left; vertical-align: top; }
856
+ .nwe-chapter-table th { color: var(--nwe-muted); font-weight: 600; background: color-mix(in srgb, var(--nwe-raised) 55%, transparent); }
857
+ .nwe-chapter-table tr:last-child td { border-bottom: 0; }
858
+ .nwe-chapter-table td.is-num { color: var(--nwe-gold); font-variant-numeric: tabular-nums; white-space: nowrap; }
859
+ .nwe-chapter-table .nwe-table-title { font-weight: 600; overflow-wrap: anywhere; }
860
+ .nwe-pager { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: var(--nwe-muted); font-size: 12px; }
801
861
  .nwe-chapter {
802
862
  overflow: hidden;
803
863
  border: 1px solid var(--nwe-border);
@@ -819,18 +879,18 @@ window.__ModuleLoader__.load({
819
879
  list-style: none;
820
880
  }
821
881
  .nwe-chapter summary::-webkit-details-marker { display: none; }
822
- .nwe-chapter-num { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; letter-spacing: .04em; }
882
+ .nwe-chapter-num { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; letter-spacing: .04em; }
823
883
  .nwe-chapter-title { overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
824
- .nwe-chapter-stage { margin-top: 2px; color: var(--nwe-muted); font-size: 10px; }
884
+ .nwe-chapter-stage { margin-top: 2px; color: var(--nwe-muted); font-size: 12px; }
825
885
  .nwe-chevron { color: var(--nwe-muted); font-size: 16px; line-height: 1; transition: transform .18s ease; }
826
886
  .nwe-chapter[open] .nwe-chevron { transform: rotate(90deg); }
827
- .nwe-chapter-meta { display: flex; align-items: center; gap: 10px; justify-content: flex-end; color: var(--nwe-muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
887
+ .nwe-chapter-meta { display: flex; align-items: center; gap: 10px; justify-content: flex-end; color: var(--nwe-muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
828
888
  .nwe-score { display: flex; flex-direction: column; gap: 4px; width: 92px; }
829
889
  .nwe-score-line { display: flex; justify-content: space-between; gap: 6px; }
830
- .nwe-score-value { color: var(--nwe-ink); font-size: 11px; }
890
+ .nwe-score-value { color: var(--nwe-ink); font-size: 12px; }
831
891
  .nwe-scorebar { overflow: hidden; height: 4px; border-radius: 99px; background: var(--nwe-border); }
832
892
  .nwe-scorebar > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-jade)), var(--nwe-gold)); }
833
- .nwe-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; font-size: 10px; white-space: nowrap; }
893
+ .nwe-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; font-size: 12px; white-space: nowrap; }
834
894
  .nwe-badge.is-ok { border-color: color-mix(in srgb, var(--nwe-jade) 45%, transparent); color: var(--nwe-jade); }
835
895
  .nwe-badge.is-bad { border-color: color-mix(in srgb, var(--nwe-rose) 45%, transparent); color: var(--nwe-rose); }
836
896
  .nwe-badge.is-muted { color: var(--nwe-muted); }
@@ -853,19 +913,20 @@ window.__ModuleLoader__.load({
853
913
  .nwe-project-choices { display: flex; flex-direction: column; gap: 8px; width: min(560px, 100%); margin-top: 16px; }
854
914
  .nwe-project-choice { display: flex; flex-direction: column; gap: 3px; padding: 10px 13px; border: 1px solid var(--nwe-border); border-radius: 10px; background: var(--nwe-raised); color: var(--nwe-ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
855
915
  .nwe-project-choice:hover { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); }
856
- .nwe-project-choice span { overflow-wrap: anywhere; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
916
+ .nwe-project-choice span { overflow-wrap: anywhere; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
857
917
  .nwe-skeleton { display: grid; gap: 10px; margin-top: 12px; }
858
918
  .nwe-skeleton-line { height: 16px; border-radius: 8px; background: linear-gradient(90deg, var(--nwe-border), color-mix(in srgb, var(--nwe-border) 40%, transparent), var(--nwe-border)); background-size: 200% 100%; animation: nwe-shimmer 1.6s linear infinite; }
859
919
  .nwe-skeleton-line.is-wide { height: 72px; }
860
920
  .nwe-skeleton-line.is-short { width: 42%; }
861
921
  .nwe-blocking { display: flex; flex-direction: column; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; }
862
922
  .nwe-block { display: flex; gap: 11px; align-items: flex-start; padding: 13px 15px; border: 1px solid var(--nwe-border); border-left: 2px solid var(--nwe-rose); border-radius: 12px; background: color-mix(in srgb, var(--nwe-surface) 92%, transparent); }
863
- .nwe-block-icon { display: grid; place-items: center; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--nwe-rose) 14%, transparent); color: var(--nwe-rose); font-size: 11px; font-weight: 700; }
864
- .nwe-block-code { color: var(--nwe-rose); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
923
+ .nwe-block-icon { display: grid; place-items: center; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--nwe-rose) 14%, transparent); color: var(--nwe-rose); font-size: 12px; font-weight: 700; }
924
+ .nwe-block-code { color: var(--nwe-rose); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
865
925
  .nwe-block-detail { margin-top: 2px; font-size: 12px; }
866
- .nwe-block-hint { margin-top: 3px; color: var(--nwe-muted); font-size: 11px; }
926
+ .nwe-block-hint { margin-top: 3px; color: var(--nwe-muted); font-size: 12px; }
867
927
  .nwe-pass { display: flex; align-items: center; gap: 9px; margin-top: 12px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--nwe-jade) 30%, var(--nwe-border)); border-radius: 12px; color: var(--nwe-jade); font-size: 12px; background: color-mix(in srgb, var(--nwe-jade) 6%, var(--nwe-surface)); }
868
- .nwe-footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 26px; padding-top: 12px; border-top: 1px solid var(--nwe-border); color: var(--nwe-muted); font-size: 10px; }
928
+ .nwe-footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 26px; padding-top: 12px; border-top: 1px solid var(--nwe-border); color: var(--nwe-muted); font-size: 12px; }
929
+ .nwe-footer-actions { display: flex; align-items: center; gap: 8px; }
869
930
  .nwe-footer code { color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
870
931
  @keyframes nwe-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
871
932
  @media (max-width: 760px) {
@@ -912,7 +973,7 @@ window.__ModuleLoader__.load({
912
973
  box-shadow: none;
913
974
  }
914
975
  .nwe-hero-title { min-width: 0; }
915
- .nwe-eyebrow { color: var(--nwe-muted); font-size: 9px; letter-spacing: .16em; }
976
+ .nwe-eyebrow { color: var(--nwe-muted); font-size: 12px; letter-spacing: .16em; }
916
977
  .nwe-title {
917
978
  margin: 4px 0 4px;
918
979
  font-family: inherit;
@@ -920,17 +981,17 @@ window.__ModuleLoader__.load({
920
981
  font-weight: 650;
921
982
  letter-spacing: -.015em;
922
983
  }
923
- .nwe-meta { gap: 7px; font-size: 11px; }
984
+ .nwe-meta { gap: 7px; font-size: 12px; }
924
985
  .nwe-actions { gap: 7px; }
925
986
  .nwe-project { flex-wrap: wrap; gap: 5px; }
926
- .nwe-project-label { font-size: 10px; }
987
+ .nwe-project-label { font-size: 12px; }
927
988
  .nwe-project-select {
928
989
  max-width: 310px;
929
990
  min-height: 32px;
930
991
  padding: 5px 26px 5px 9px;
931
992
  border-radius: 8px;
932
993
  background: var(--nwe-surface);
933
- font-size: 11px;
994
+ font-size: 12px;
934
995
  }
935
996
  .nwe-project-search {
936
997
  width: 124px;
@@ -941,19 +1002,19 @@ window.__ModuleLoader__.load({
941
1002
  background: var(--nwe-surface);
942
1003
  color: var(--nwe-ink);
943
1004
  font: inherit;
944
- font-size: 11px;
1005
+ font-size: 12px;
945
1006
  }
946
1007
  .nwe-project-toggle {
947
1008
  display: inline-flex;
948
1009
  align-items: center;
949
1010
  gap: 4px;
950
1011
  color: var(--nwe-muted);
951
- font-size: 10px;
1012
+ font-size: 12px;
952
1013
  cursor: pointer;
953
1014
  white-space: nowrap;
954
1015
  }
955
1016
  .nwe-refresh, .nwe-help { min-height: 32px; padding: 6px 10px; border-radius: 8px; }
956
- .nwe-rootline { font-size: 9px; margin-top: 3px; }
1017
+ .nwe-rootline { font-size: 12px; margin-top: 3px; }
957
1018
  .nwe-stats {
958
1019
  display: grid;
959
1020
  grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -971,11 +1032,11 @@ window.__ModuleLoader__.load({
971
1032
  background: transparent;
972
1033
  }
973
1034
  .nwe-stat:last-child { border-right: 0; }
974
- .nwe-stat-label { font-size: 10px; }
1035
+ .nwe-stat-label { font-size: 12px; }
975
1036
  .nwe-stat-value { margin-top: 1px; font-size: 18px; font-weight: 600; }
976
1037
  .nwe-section { margin-top: 18px; }
977
1038
  .nwe-section-title { font-family: inherit; font-size: 14px; font-weight: 650; }
978
- .nwe-section-note { font-size: 10px; }
1039
+ .nwe-section-note { font-size: 12px; }
979
1040
  .nwe-next {
980
1041
  margin-top: 12px;
981
1042
  padding: 14px 16px;
@@ -987,8 +1048,8 @@ window.__ModuleLoader__.load({
987
1048
  .nwe-next-icon { width: 24px; height: 24px; border-radius: 7px; }
988
1049
  .nwe-next-body { min-width: 0; }
989
1050
  .nwe-next-title { font-size: 14px; }
990
- .nwe-next-detail { font-size: 11px; }
991
- .nwe-next-tool { font-size: 9px; }
1051
+ .nwe-next-detail { font-size: 12px; }
1052
+ .nwe-next-tool { font-size: 12px; }
992
1053
  .nwe-desk {
993
1054
  display: grid;
994
1055
  grid-template-columns: minmax(0, 1.2fr) minmax(300px, .8fr);
@@ -1004,8 +1065,8 @@ window.__ModuleLoader__.load({
1004
1065
  gap: 8px;
1005
1066
  margin-top: 16px;
1006
1067
  }
1007
- .nwe-desk-kicker { color: var(--nwe-muted); font-size: 9px; font-weight: 650; letter-spacing: .14em; text-transform: uppercase; }
1008
- .nwe-desk-note { color: var(--nwe-gold); font-size: 10px; }
1068
+ .nwe-desk-kicker { color: var(--nwe-muted); font-size: 12px; font-weight: 650; letter-spacing: .14em; text-transform: uppercase; }
1069
+ .nwe-desk-note { color: var(--nwe-gold); font-size: 12px; }
1009
1070
  .nwe-desk-main .nwe-phases { margin-top: 8px; }
1010
1071
  .nwe-desk-side .nwe-book-grid { grid-template-columns: 1fr; }
1011
1072
  .nwe-desk-side .nwe-section { margin-top: 16px; }
@@ -1016,27 +1077,27 @@ window.__ModuleLoader__.load({
1016
1077
  gap: 8px;
1017
1078
  align-items: baseline;
1018
1079
  padding: 7px 9px;
1019
- border: 1px solid color-mix(in srgb, #c9a227 24%, var(--nwe-border));
1080
+ border: 1px solid color-mix(in srgb, var(--nwe-gold) 24%, var(--nwe-border));
1020
1081
  border-radius: 7px;
1021
- background: color-mix(in srgb, #c9a227 5%, transparent);
1022
- font-size: 11px;
1082
+ background: color-mix(in srgb, var(--nwe-gold) 5%, transparent);
1083
+ font-size: 12px;
1023
1084
  }
1024
- .nwe-advisory-code { color: #b08a1e; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; white-space: nowrap; }
1085
+ .nwe-advisory-code { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: nowrap; }
1025
1086
  .nwe-advisory-detail { color: var(--nwe-ink); line-height: 1.5; }
1026
- .nwe-advisory-hint { color: var(--nwe-muted); font-size: 9px; white-space: nowrap; }
1087
+ .nwe-advisory-hint { color: var(--nwe-muted); font-size: 12px; white-space: nowrap; }
1027
1088
  .nwe-advisory-empty {
1028
1089
  margin-top: 8px;
1029
1090
  padding: 8px 10px;
1030
- border: 1px solid color-mix(in srgb, #c9a227 18%, var(--nwe-border));
1091
+ border: 1px solid color-mix(in srgb, var(--nwe-gold) 18%, var(--nwe-border));
1031
1092
  border-radius: 7px;
1032
1093
  color: var(--nwe-muted);
1033
- font-size: 11px;
1094
+ font-size: 12px;
1034
1095
  }
1035
1096
 
1036
- .nwe-pending-section { border-left: 3px solid #c9a227; padding-left: 10px; }
1097
+ .nwe-pending-section { border-left: 3px solid var(--nwe-gold); padding-left: 10px; }
1037
1098
  .nwe-pending-error { border-left-color: #b3261e; }
1038
- .nwe-pending-detail { margin: 8px 0 0; color: var(--nwe-ink); font-size: 11px; line-height: 1.6; }
1039
- .nwe-pending-hint { margin: 4px 0 0; color: var(--nwe-muted); font-size: 10px; line-height: 1.5; }
1099
+ .nwe-pending-detail { margin: 8px 0 0; color: var(--nwe-ink); font-size: 12px; line-height: 1.6; }
1100
+ .nwe-pending-hint { margin: 4px 0 0; color: var(--nwe-muted); font-size: 12px; line-height: 1.5; }
1040
1101
 
1041
1102
  .nwe-v6-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
1042
1103
  .nwe-v6-card { border: 1px solid var(--nwe-border); border-radius: 8px; padding: 10px; background: var(--nwe-surface); }
@@ -1044,16 +1105,17 @@ window.__ModuleLoader__.load({
1044
1105
  .nwe-v6-card > summary { cursor: pointer; font: 600 12px/1.4 var(--nwe-font); color: var(--nwe-ink); }
1045
1106
  .nwe-v6-card > summary:focus-visible { outline: 2px solid var(--nwe-accent); outline-offset: 2px; }
1046
1107
  .nwe-v6-list { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 4px; }
1047
- .nwe-v6-list li { display: grid; grid-template-columns: 7.5em minmax(0, 1fr); gap: 8px; font-size: 10px; line-height: 1.55; color: var(--nwe-muted); }
1108
+ .nwe-v6-list li, .nwe-v6-list b, .nwe-v6-list span { min-width: 0; overflow-wrap: anywhere; }
1109
+ .nwe-v6-list li { display: grid; grid-template-columns: 7.5em minmax(0, 1fr); gap: 8px; font-size: 12px; line-height: 1.55; color: var(--nwe-muted); }
1048
1110
  .nwe-v6-list b { color: var(--nwe-ink); font-weight: 600; overflow-wrap: anywhere; }
1049
1111
  .nwe-v6-bars { display: grid; gap: 4px; margin-top: 8px; }
1050
- .nwe-v6-bar { display: grid; grid-template-columns: 7em minmax(0, 1fr) 3em; gap: 6px; align-items: center; font-size: 10px; color: var(--nwe-muted); }
1112
+ .nwe-v6-bar { display: grid; grid-template-columns: 7em minmax(0, 1fr) 3em; gap: 6px; align-items: center; font-size: 12px; color: var(--nwe-muted); }
1051
1113
  .nwe-v6-bar span { height: 4px; border-radius: 2px; background: var(--nwe-border); overflow: hidden; }
1052
1114
  .nwe-v6-bar span i { display: block; height: 100%; background: var(--nwe-accent); }
1053
- .nwe-v6-note { margin: 8px 0 0; font-size: 10px; line-height: 1.5; color: var(--nwe-muted); }
1115
+ .nwe-v6-note { margin: 8px 0 0; font-size: 12px; line-height: 1.5; color: var(--nwe-muted); }
1054
1116
  .nwe-v6-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
1055
- .nwe-v6-tags span { border: 1px solid var(--nwe-border); border-radius: 999px; padding: 2px 7px; font-size: 9px; color: var(--nwe-muted); }
1056
- .nwe-chapter-extra { margin-top: 6px; font-size: 10px; color: var(--nwe-muted); line-height: 1.5; overflow-wrap: anywhere; }
1117
+ .nwe-v6-tags span { border: 1px solid var(--nwe-border); border-radius: 999px; padding: 2px 7px; font-size: 12px; color: var(--nwe-muted); }
1118
+ .nwe-chapter-extra { margin-top: 6px; font-size: 12px; color: var(--nwe-muted); line-height: 1.5; overflow-wrap: anywhere; }
1057
1119
  @media (max-width: 640px) { .nwe-v6-grid { grid-template-columns: 1fr; } .nwe-v6-card[open] { grid-column: auto; } }
1058
1120
  .nwe-legacy {
1059
1121
  display: flex;
@@ -1063,10 +1125,10 @@ window.__ModuleLoader__.load({
1063
1125
  border: 1px dashed var(--nwe-border);
1064
1126
  border-radius: 7px;
1065
1127
  color: var(--nwe-muted);
1066
- font-size: 10px;
1128
+ font-size: 12px;
1067
1129
  line-height: 1.5;
1068
1130
  }
1069
- .nwe-legacy code { color: var(--nwe-muted); font-size: 9px; white-space: nowrap; }
1131
+ .nwe-legacy code { color: var(--nwe-muted); font-size: 12px; white-space: nowrap; }
1070
1132
  .nwe-chapters-section { margin-top: 20px; }
1071
1133
  .nwe-desk-main .nwe-chapters {
1072
1134
  max-height: 460px;
@@ -1146,7 +1208,7 @@ window.__ModuleLoader__.load({
1146
1208
  title: "写书级蓝图与卷结构",
1147
1209
  tool: "novel_blueprint",
1148
1210
  body: "让模型先定 premise(故事前提)、logline(一句话卖点)、themes(主题)、tone(基调)、pov(视角)、audience(读者)、selling_points(卖点)、ending(结局方向)和 volumes(每卷起止章、目标、弧线、高潮)。",
1149
- note: "蓝图不是正文,可以后补。严格大纲是可选开关:strict:true 时每章必须先有章卡;不开启也能先写,但章卡一旦存在,标题、场景、目标字数和 cast 仍会校验。"
1211
+ note: "蓝图不是正文,可以后补。首次提交章卡时 strict 默认开启;要宽松写作可在蓝图里显式 strict:false 关闭。strict 开启后每章必须先有章卡;章卡一旦存在,标题、场景、目标字数和 cast 仍会校验。"
1150
1212
  },
1151
1213
  {
1152
1214
  title: "排章卡,或导入现成大纲",
@@ -1218,8 +1280,8 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1218
1280
  正文只通过 write_draft/revise 写;不要直接改 state/、chapters/ 或 outline/。
1219
1281
  每次提交前先领取当前阶段任务书,失败时按返回的 code 和 blocking 一次改完。`;
1220
1282
  const TUTORIAL_NEXT_PROMPT = `继续写当前项目的下一章。先调用 novel_pipeline(或 novel_status)确认当前阶段、项目根目录和下一步工具,不要猜阶段。
1221
- 如果当前会话还没绑定或不确定是哪本书:先用 /novel current 看会话绑定,再调用 novel_status 看 selection;需要切换时用 /novel use <路径|书名|.>,不要靠 active 猜。
1222
- 第一次调用带 project_root 时,它会成为当前会话的持久绑定;后续无 root 调用会继续这本书。
1283
+ 如果当前会话还没绑定或不确定是哪本书:先让用户执行 /novel current 或 /novel use(Agent 不执行 slash 命令),同时调用 novel_status 看 selection;不要靠 active 猜。
1284
+ 第一次带 project_root 的项目调用会成为当前会话的持久绑定;后续无 root 调用会继续这本书。纯文本的 novel_word_count 和 novel_diff 不读取项目,也不会建立绑定。
1223
1285
 
1224
1286
  如果下一章还没有章卡:用 novel_outline 只提交下一章的卡(按 num upsert),省略的已有卡会保持不动;不要重交全量,也不要用空数组清空。要删卡必须显式传 remove。
1225
1287
  如果 targets.total_chapters 已经写满:先用 novel_amend 把 total_chapters 调到至少包含下一章,不能低于已归档章数。
@@ -1231,17 +1293,17 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1231
1293
  className: "nwe-stat",
1232
1294
  key: label
1233
1295
  }, (0, react.createElement)("div", { className: "nwe-stat-label" }, label), (0, react.createElement)("div", { className: "nwe-stat-value" }, value, (0, react.createElement)("span", { className: "nwe-stat-unit" }, unit)));
1234
- const chapterCard = (row) => (0, react.createElement)("li", { key: row.num }, (0, react.createElement)("details", { className: `nwe-chapter is-${row.tone}` }, (0, react.createElement)("summary", { "aria-label": `第 ${row.num} 章 ${row.title},${row.badge},${row.score}` }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-chapter-num" }, `CH ${row.num.padStart(2, "0")}`)), (0, react.createElement)("div", { style: { minWidth: 0 } }, (0, react.createElement)("div", { className: "nwe-chapter-title" }, row.title), (0, react.createElement)("div", { className: "nwe-chapter-stage" }, row.stageLabel)), (0, react.createElement)("div", { className: "nwe-chapter-meta" }, (0, react.createElement)("span", null, `${row.words} 字`), (0, react.createElement)("div", { className: "nwe-score" }, (0, react.createElement)("div", { className: "nwe-score-line" }, (0, react.createElement)("span", { className: "nwe-score-value" }, row.score), (0, react.createElement)("span", { className: "nwe-chapter-stage" }, `${row.scorePercent}%`)), (0, react.createElement)("div", {
1296
+ const chapterCard = (row) => (0, react.createElement)("li", { key: row.num }, (0, react.createElement)("details", { className: `nwe-chapter is-${row.tone}` }, (0, react.createElement)("summary", { "aria-label": `第 ${row.num} 章 ${row.title},${row.badge},${row.score}` }, (0, react.createElement)("span", null, (0, react.createElement)("span", { className: "nwe-chapter-num" }, `CH ${row.num.padStart(2, "0")}`)), (0, react.createElement)("span", { style: { minWidth: 0 } }, (0, react.createElement)("span", { className: "nwe-chapter-title" }, row.title), (0, react.createElement)("span", { className: "nwe-chapter-stage" }, row.stageLabel)), (0, react.createElement)("span", { className: "nwe-chapter-meta" }, (0, react.createElement)("span", null, `${row.words} 字`), (0, react.createElement)("span", { className: "nwe-score" }, (0, react.createElement)("span", { className: "nwe-score-line" }, (0, react.createElement)("span", { className: "nwe-score-value" }, row.score), (0, react.createElement)("span", { className: "nwe-chapter-stage" }, row.scorePercent === null ? "未评审" : `${row.scorePercent}%`)), row.scoreValue === null ? null : (0, react.createElement)("span", {
1235
1297
  className: "nwe-scorebar",
1236
1298
  role: "progressbar",
1237
1299
  "aria-valuemin": 0,
1238
1300
  "aria-valuemax": 150,
1239
1301
  "aria-valuenow": row.scoreValue,
1240
1302
  "aria-label": `${row.title} 评分`
1241
- }, (0, react.createElement)("span", { style: { width: `${row.scorePercent}%` } }))), (0, react.createElement)("span", { className: `nwe-badge is-${row.tone}` }, row.badge)), (0, react.createElement)("span", {
1303
+ }, (0, react.createElement)("span", { style: { width: `${row.scorePercent ?? 0}%` } }))), (0, react.createElement)("span", { className: `nwe-badge is-${row.tone}` }, row.badge)), (0, react.createElement)("span", {
1242
1304
  className: "nwe-chevron",
1243
1305
  "aria-hidden": "true"
1244
- }, "›")), row.details === "" ? null : (0, react.createElement)("div", { className: "nwe-chapter-detail" }, row.details), (0, react.createElement)("div", { className: "nwe-chapter-extra" }, `${row.sceneType || "—"} · 对话 ${row.dialogueRatio === null ? "—" : `${Math.round(row.dialogueRatio * 100)}%`} · 钩子 ${row.hookStrength ?? "—"} · 终检 ${row.finalCheck ?? "—"} · 评 ${row.reviews} / 修 ${row.revisions} / 检 ${row.checks} / 回滚 ${row.rollbacks} · 归档 ${row.archivedAt ?? "—"}`)));
1306
+ }, "›")), row.details === "" ? null : (0, react.createElement)("div", { className: "nwe-chapter-detail" }, row.details), (0, react.createElement)("div", { className: "nwe-chapter-extra" }, `${row.sceneType || "—"} · 对话 ${row.dialogueRatio === null ? "—" : `${Math.round(row.dialogueRatio * 100)}%`} · 钩子 ${row.hookStrength ?? "—"} · 终检 ${finalCheckLabel(row.finalCheck)} · 评 ${row.reviews} / 修 ${row.revisions} / 检 ${row.checks} / 回滚 ${row.rollbacks} · 归档 ${localTime(row.archivedAt)}`)));
1245
1307
  const phases = (view) => (0, react.createElement)("ol", {
1246
1308
  className: "nwe-phases",
1247
1309
  "aria-label": "创作阶段"
@@ -1290,7 +1352,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1290
1352
  }, `${label} ${value}`))), (0, react.createElement)("div", {
1291
1353
  className: "nwe-book-head",
1292
1354
  style: { marginTop: 12 }
1293
- }, (0, react.createElement)("span", { className: "nwe-book-title" }, "质量趋势"), (0, react.createElement)("span", { className: "nwe-book-note" }, bars.length === 0 ? "—" : `${view.quality.lastTenAverage.toFixed(1)} 近十均分 · 趋势 ${view.quality.trendText} · ${view.quality.samples} 章 / ${view.quality.attempts} 评`)), (0, react.createElement)("div", {
1355
+ }, (0, react.createElement)("span", { className: "nwe-book-title" }, "质量趋势"), (0, react.createElement)("span", { className: "nwe-book-note" }, bars.length === 0 ? "—" : `${view.quality.lastTenAverage === null ? "—" : view.quality.lastTenAverage.toFixed(1)} 近十均分 · 趋势 ${view.quality.samples === 0 ? "见质量页" : view.quality.trendText} · ${view.quality.samples === 0 ? "可读评审 —" : `${view.quality.samples} 章 / ${view.quality.attempts} 评`}`)), (0, react.createElement)("div", {
1294
1356
  className: "nwe-bars",
1295
1357
  role: "img",
1296
1358
  "aria-label": "最近十章评分"
@@ -1301,9 +1363,19 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1301
1363
  title: `${score}/150`
1302
1364
  }))), (0, react.createElement)("div", { className: `nwe-health${view.health.ok ? "" : " is-bad"}` }, (0, react.createElement)("span", { "aria-hidden": "true" }, view.health.ok ? "✓" : "!"), (0, react.createElement)("span", null, view.health.ok ? "项目健康:没有阻塞或损坏状态" : `需要处理:${view.health.blocking} 条门控 · ${view.health.stateErrors} 个损坏文档${view.health.pending ? " · 有 pending 事务" : ""}`)))));
1303
1365
  };
1366
+ const localTime = (value) => {
1367
+ if (value === null || value === void 0 || value === "") return "—";
1368
+ const parsed = new Date(value);
1369
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString("zh-CN", {
1370
+ month: "2-digit",
1371
+ day: "2-digit",
1372
+ hour: "2-digit",
1373
+ minute: "2-digit"
1374
+ });
1375
+ };
1304
1376
  const activitySection = (view) => {
1305
1377
  if (view.activity.length === 0) return null;
1306
- return (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "最近活动"), (0, react.createElement)("span", { className: "nwe-section-note" }, "来自事务日志")), (0, react.createElement)("ul", { className: "nwe-activity" }, ...view.activity.slice(-5).map((row, index) => (0, react.createElement)("li", { key: `${row.at}-${row.op}-${index}` }, (0, react.createElement)("span", null, row.at.slice(0, 16).replace("T", " ")), (0, react.createElement)("code", null, row.op), (0, react.createElement)("span", null, row.chapter === 0 ? row.status : `第 ${row.chapter} 章 · ${row.status}`)))));
1378
+ return (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "最近活动"), (0, react.createElement)("span", { className: "nwe-section-note" }, "来自事务日志")), (0, react.createElement)("ul", { className: "nwe-activity" }, ...view.activity.slice(-5).map((row, index) => (0, react.createElement)("li", { key: `${row.at}-${row.op}-${index}` }, (0, react.createElement)("time", { dateTime: row.at }, localTime(row.at)), (0, react.createElement)("code", null, row.op), (0, react.createElement)("span", null, row.chapter === 0 ? statusLabel(row.status) : `第 ${row.chapter} 章 · ${statusLabel(row.status)}`)))));
1307
1379
  };
1308
1380
  const advisoriesSection = (view) => {
1309
1381
  if (view.advisories.length === 0 && view.acknowledgedAdvisories === 0) return null;
@@ -1322,7 +1394,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1322
1394
  return (0, react.createElement)("section", {
1323
1395
  className: `nwe-section nwe-pending-section${partial ? " nwe-pending-error" : ""}`,
1324
1396
  "aria-label": "待恢复事务"
1325
- }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, partial ? "事务部分完成" : "待恢复事务"), (0, react.createElement)("span", { className: "nwe-section-note" }, view.pending.state)), (0, react.createElement)("p", { className: "nwe-pending-detail" }, view.pending.detail), (0, react.createElement)("p", { className: "nwe-pending-hint" }, partial ? `检查 backups 与 .staging/${view.pending.txid}/before/,不要整项目恢复。` : "下一次写操作会自动收尾;只读面不会改动事务。"));
1397
+ }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, partial ? "事务部分完成" : "待恢复事务"), (0, react.createElement)("span", { className: "nwe-section-note" }, partial ? "部分完成" : "待恢复")), (0, react.createElement)("p", { className: "nwe-pending-detail" }, view.pending.detail), (0, react.createElement)("p", { className: "nwe-pending-hint" }, partial ? `检查 backups 与 .staging/${view.pending.txid}/before/,不要整项目恢复。` : "下一次写操作会自动收尾;只读面不会改动事务。"));
1326
1398
  };
1327
1399
  const legacySection = (view) => {
1328
1400
  if (view.legacy.length === 0) return null;
@@ -1334,7 +1406,14 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1334
1406
  key: `${item.code}-${item.detail}`
1335
1407
  }, (0, react.createElement)("code", null, item.code), (0, react.createElement)("span", null, item.detail)))));
1336
1408
  };
1337
- const footer = () => (0, react.createElement)("footer", { className: "nwe-footer" }, (0, react.createElement)("span", null, "只读面板 · 数据来自引擎 · 每 15 秒自动同步"), (0, react.createElement)("code", null, "novel-workshop"));
1409
+ const footer = (view, onRefresh) => (0, react.createElement)("footer", { className: "nwe-footer" }, (0, react.createElement)("span", {
1410
+ role: "status",
1411
+ "aria-live": "polite"
1412
+ }, view.syncError !== void 0 && view.syncError !== null && view.syncError !== "" ? `同步失败:${view.syncError}` : view.lastSync !== void 0 && view.lastSync !== null ? `只读面板 · 数据来自引擎 · 更新于 ${new Date(view.lastSync).toLocaleTimeString("zh-CN")}` : "只读面板 · 数据来自引擎 · 等待首次同步"), (0, react.createElement)("span", { className: "nwe-footer-actions" }, view.syncError !== void 0 && view.syncError !== null && view.syncError !== "" ? (0, react.createElement)("button", {
1413
+ type: "button",
1414
+ className: "nwe-copy",
1415
+ onClick: onRefresh
1416
+ }, "重试") : null, (0, react.createElement)("code", null, "novel-workshop")));
1338
1417
  const loadingState = () => (0, react.createElement)("div", {
1339
1418
  className: "nwe-section",
1340
1419
  role: "status",
@@ -1401,7 +1480,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1401
1480
  onChange: (event) => {
1402
1481
  onSelect(event.target.value === "" ? null : event.target.value);
1403
1482
  }
1404
- }, view.selectedRoot === null ? (0, react.createElement)("option", { value: "" }, "选择项目…") : null, ...groups.filter((group) => group.items.length > 0).map((group) => (0, react.createElement)("optgroup", {
1483
+ }, (0, react.createElement)("option", { value: "" }, view.selectedRoot === null ? "跟随当前项目(自动)" : "跟随当前项目"), ...groups.filter((group) => group.items.length > 0).map((group) => (0, react.createElement)("optgroup", {
1405
1484
  key: group.key,
1406
1485
  label: group.label
1407
1486
  }, ...group.items.map((project) => (0, react.createElement)("option", {
@@ -1416,10 +1495,10 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1416
1495
  const choosePanel = (view, onSelect) => {
1417
1496
  const preferred = view.projects.filter((project) => project.group !== "missing");
1418
1497
  const shown = preferred.length > 0 ? preferred : view.projects;
1419
- return (0, react.createElement)("div", {
1420
- className: "nwe-state",
1498
+ return (0, react.createElement)("div", { className: "nwe-state" }, (0, react.createElement)("div", { className: "nwe-state-icon" }, stateIcon()), (0, react.createElement)("div", { className: "nwe-state-title" }, "发现多本书,先选一本"), (0, react.createElement)("div", {
1499
+ className: "nwe-state-text",
1421
1500
  role: "status"
1422
- }, (0, react.createElement)("div", { className: "nwe-state-icon" }, stateIcon()), (0, react.createElement)("div", { className: "nwe-state-title" }, "发现多本书,先选一本"), (0, react.createElement)("div", { className: "nwe-state-text" }, view.subtitle), (0, react.createElement)("div", { className: "nwe-project-choices" }, ...shown.map((project) => (0, react.createElement)("button", {
1501
+ }, view.subtitle), (0, react.createElement)("div", { className: "nwe-project-choices" }, ...shown.map((project) => (0, react.createElement)("button", {
1423
1502
  key: project.root,
1424
1503
  type: "button",
1425
1504
  className: "nwe-project-choice",
@@ -1477,6 +1556,28 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1477
1556
  className: "nwe-step",
1478
1557
  key: step.title
1479
1558
  }, (0, react.createElement)("div", { className: "nwe-step-head" }, (0, react.createElement)("span", { className: "nwe-step-no" }, String(index + 1).padStart(2, "0")), (0, react.createElement)("h4", null, step.title), (0, react.createElement)("code", { className: "nwe-step-tool" }, step.tool)), (0, react.createElement)("p", null, step.body), (0, react.createElement)("p", null, (0, react.createElement)("strong", null, "注意:"), step.note));
1559
+ const CopyPrompt = ({ text, label }) => {
1560
+ const [status, setStatus] = (0, react.useState)("");
1561
+ const copy = (0, react.useCallback)(async () => {
1562
+ try {
1563
+ const clipboard = globalThis.navigator?.clipboard;
1564
+ if (clipboard?.writeText === void 0) throw new Error("clipboard unavailable");
1565
+ await clipboard.writeText(text);
1566
+ setStatus("已复制到剪贴板");
1567
+ } catch {
1568
+ setStatus("复制失败:请手动选择下方文本,或检查浏览器剪贴板权限");
1569
+ }
1570
+ }, [text]);
1571
+ return (0, react.createElement)("div", { className: "nwe-copy-block" }, (0, react.createElement)("pre", { className: "nwe-prompt" }, text), (0, react.createElement)("div", { className: "nwe-copy-row" }, (0, react.createElement)("button", {
1572
+ type: "button",
1573
+ className: "nwe-copy",
1574
+ onClick: () => void copy()
1575
+ }, label), (0, react.createElement)("span", {
1576
+ className: "nwe-copy-status",
1577
+ role: "status",
1578
+ "aria-live": "polite"
1579
+ }, status)));
1580
+ };
1480
1581
  const tutorialDialog = (onClose, closeRef) => (0, react.createElement)("div", {
1481
1582
  className: "nwe-modal-backdrop",
1482
1583
  onMouseDown: (event) => {
@@ -1515,7 +1616,13 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1515
1616
  ref: closeRef,
1516
1617
  onClick: onClose,
1517
1618
  "aria-label": "关闭使用教程"
1518
- }, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。"), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "项目状态:"), "选择器里 choose 表示发现多本书、还没选;项目不在了表示目录缺失,绑定仍会保留;不是项目表示目录还在但没有 novel.yaml 或项目状态;项目不可读表示权限或 IO 错误,先修权限或换一本。面板选择只影响查看,不改变任何会话的写作绑定。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "面板用顶部项目选择器切换查看对象;选择只改 ?root= 和浏览器 localStorage,不调用 activate、不写会话绑定。写作项目请用 /novel use 或显式 project_root。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "选错书、想清掉绑定怎么办?"), (0, react.createElement)("p", null, "先用 /novel current 看当前会话绑定;要换书用 /novel use <路径|书名|.>,要清掉只影响当前会话的绑定用 /novel unbind。不要靠 active 猜,也不要手改 state/session-bindings.yaml。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "扫描到了,为什么还没切过去?"), (0, react.createElement)("p", null, "scan 只读发现;scan --register 只登记项目,不选择、不改 active、不绑会话。真正切换写作项目要用 /novel use,或第一次调用显式传 project_root。")))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "可以直接复制给模型的起手话术"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "续写下一章时,直接复制这段给 Agent"), (0, react.createElement)("p", null, "这段会先确认状态,再处理缺章卡和目标章数已满,最后按 P1→P8 推进;归档前会要求你确认 state_delta。"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_NEXT_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一章完成检查清单"), (0, react.createElement)("ul", { className: "nwe-checklist" }, (0, react.createElement)("li", null, "本章 P1/P2 计划已提交,P3 正文已通过格式与字数门控。"), (0, react.createElement)("li", null, "P4 评审 total 对账无误,没有未修 blocker。"), (0, react.createElement)("li", null, "P6 五个语义项都有不同句子的正文引用,完整 scope 通过。"), (0, react.createElement)("li", null, "P8 任务书当前,novel_archive 带完整 state_delta 成功。"), (0, react.createElement)("li", null, "面板出现已归档章节,门控区没有新的阻塞。")))), (0, react.createElement)("div", { className: "nwe-modal-foot" }, "教程基于 0.2.7 · 具体字段以当前任务书和工具返回为准 · 面板只读")));
1619
+ }, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。"), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "项目状态:"), "选择器里 choose 表示发现多本书、还没选;项目不在了表示目录缺失,绑定仍会保留;不是项目表示目录还在但没有 novel.yaml 或项目状态;项目不可读表示权限或 IO 错误,先修权限或换一本。面板选择只影响查看,不改变任何会话的写作绑定。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "面板用顶部项目选择器切换查看对象;选择只改 ?root= 和浏览器 localStorage,不调用 activate、不写会话绑定。写作项目请用 /novel use 或显式 project_root。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "选错书、想清掉绑定怎么办?"), (0, react.createElement)("p", null, "先用 /novel current 看当前会话绑定;要换书用 /novel use <路径|书名|.>,要清掉只影响当前会话的绑定用 /novel unbind。不要靠 active 猜,也不要手改 state/session-bindings.yaml。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "扫描到了,为什么还没切过去?"), (0, react.createElement)("p", null, "scan 只读发现;scan --register 只登记项目,不选择、不改 active、不绑会话。真正切换写作项目要用 /novel use,或第一次调用显式传 project_root。")))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "可以直接复制给模型的起手话术"), (0, react.createElement)(CopyPrompt, {
1620
+ text: TUTORIAL_PROMPT,
1621
+ label: "复制起手话术"
1622
+ })), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "续写下一章时,直接复制这段给 Agent"), (0, react.createElement)("p", null, "这段会先确认状态,再处理缺章卡和目标章数已满,最后按 P1→P8 推进;归档前会要求你确认 state_delta。"), (0, react.createElement)(CopyPrompt, {
1623
+ text: TUTORIAL_NEXT_PROMPT,
1624
+ label: "复制续写指令"
1625
+ })), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一章完成检查清单"), (0, react.createElement)("ul", { className: "nwe-checklist" }, (0, react.createElement)("li", null, "本章 P1/P2 计划已提交,P3 正文已通过格式与字数门控。"), (0, react.createElement)("li", null, "P4 评审 total 对账无误,没有未修 blocker。"), (0, react.createElement)("li", null, "P6 五个语义项都有不同句子的正文引用,完整 scope 通过。"), (0, react.createElement)("li", null, "P8 任务书当前,novel_archive 带完整 state_delta 成功。"), (0, react.createElement)("li", null, "面板出现已归档章节,门控区没有新的阻塞。")))), (0, react.createElement)("div", { className: "nwe-modal-foot" }, `教程基于 ${PANEL_VERSION} · 具体字段以当前任务书和工具返回为准 · 面板只读`)));
1519
1626
  const NovelHelp = () => {
1520
1627
  const [open, setOpen] = (0, react.useState)(false);
1521
1628
  const triggerRef = (0, react.useRef)(null);
@@ -1535,6 +1642,18 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1535
1642
  wasOpen.current = true;
1536
1643
  const previousOverflow = doc.body.style.overflow;
1537
1644
  doc.body.style.overflow = "hidden";
1645
+ const panelRoot = triggerRef.current?.closest("[data-plugin=\"dsh-novel-write-engine\"]") ?? doc.querySelector("[data-plugin=\"dsh-novel-write-engine\"]");
1646
+ const inerted = [];
1647
+ if (panelRoot !== null) for (const child of Array.from(panelRoot.children)) {
1648
+ if (child.classList.contains("nwe-modal-backdrop")) continue;
1649
+ inerted.push({
1650
+ element: child,
1651
+ inert: child.getAttribute("inert"),
1652
+ aria: child.getAttribute("aria-hidden")
1653
+ });
1654
+ child.setAttribute("inert", "");
1655
+ child.setAttribute("aria-hidden", "true");
1656
+ }
1538
1657
  const onKeyDown = (event) => {
1539
1658
  if (event.key === "Escape") {
1540
1659
  event.preventDefault();
@@ -1546,8 +1665,18 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1546
1665
  return () => {
1547
1666
  doc.removeEventListener("keydown", onKeyDown);
1548
1667
  doc.body.style.overflow = previousOverflow;
1668
+ for (const item of inerted) {
1669
+ if (item.inert === null) item.element.removeAttribute("inert");
1670
+ else item.element.setAttribute("inert", item.inert);
1671
+ if (item.aria === null) item.element.removeAttribute("aria-hidden");
1672
+ else item.element.setAttribute("aria-hidden", item.aria);
1673
+ }
1549
1674
  };
1550
1675
  }, [open]);
1676
+ const doc = globalThis.document;
1677
+ const panelRoot = triggerRef.current?.closest("[data-plugin=\"dsh-novel-write-engine\"]") ?? doc?.querySelector("[data-plugin=\"dsh-novel-write-engine\"]") ?? null;
1678
+ const dialog = open ? tutorialDialog(close, closeRef) : null;
1679
+ const overlay = dialog !== null && panelRoot !== null ? (0, react_dom.createPortal)(dialog, panelRoot) : dialog;
1551
1680
  return (0, react.createElement)(react.Fragment, null, (0, react.createElement)("button", {
1552
1681
  type: "button",
1553
1682
  className: "nwe-help",
@@ -1557,7 +1686,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1557
1686
  "aria-haspopup": "dialog",
1558
1687
  "aria-expanded": open,
1559
1688
  title: "插件使用教程"
1560
- }, helpIcon(), (0, react.createElement)("span", { className: "nwe-help-label" }, "教程")), open ? tutorialDialog(close, closeRef) : null);
1689
+ }, helpIcon(), (0, react.createElement)("span", { className: "nwe-help-label" }, "教程")), overlay);
1561
1690
  };
1562
1691
  const nextCard = (view) => (0, react.createElement)("section", {
1563
1692
  className: "nwe-next",
@@ -1576,7 +1705,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1576
1705
  className: "nwe-block-icon",
1577
1706
  "aria-hidden": "true"
1578
1707
  }, "!"), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-block-code" }, item.code), (0, react.createElement)("div", { className: "nwe-block-detail" }, item.detail), item.hint === "" ? null : (0, react.createElement)("div", { className: "nwe-block-hint" }, item.hint))))));
1579
- const chaptersSection = (view) => (0, react.createElement)("section", { className: "nwe-section nwe-chapters-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, `${view.stats.chapters} 条记录${view.stats.chapters > view.rows.filter((row) => row.tone === "ok").length ? ` · 显示最近 ${view.rows.length} 条` : ""}`)), view.rows.length === 0 ? (0, react.createElement)("div", {
1708
+ const chaptersSection = (view) => (0, react.createElement)("section", { className: "nwe-section nwe-chapters-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, `${view.stats.chapters} 已归档 · 显示 ${view.rows.length} 条${view.stats.chapters > view.rows.filter((row) => row.tone === "ok").length ? `(最近 ${view.rows.length} 条)` : ""}`)), view.rows.length === 0 ? (0, react.createElement)("div", {
1580
1709
  className: "nwe-empty",
1581
1710
  style: {
1582
1711
  marginTop: 12,
@@ -1592,10 +1721,17 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1592
1721
  const bookDataSection = (view) => {
1593
1722
  const data = view.bookData;
1594
1723
  if (data === null) return null;
1724
+ const currentLabel = data.current.stage === "done" ? data.progress.target !== null && data.progress.archived >= data.progress.target ? "全书完成" : `下一章 ${data.current.chapter} · 未开始` : `${data.current.chapter} · ${data.current.stage}`;
1595
1725
  const dims = data.quality.dimensions.map((dimension) => (0, react.createElement)("div", {
1596
1726
  className: "nwe-v6-bar",
1597
1727
  key: dimension.id
1598
- }, (0, react.createElement)("span", null, dimension.id), (0, react.createElement)("span", null, (0, react.createElement)("i", { style: { width: `${Math.min(100, Math.max(0, dimension.average / 10 * 100))}%` } })), (0, react.createElement)("span", null, dimension.samples === 0 ? "—" : dimension.average.toFixed(1))));
1728
+ }, (0, react.createElement)("span", null, dimension.id), (0, react.createElement)("span", {
1729
+ role: "progressbar",
1730
+ "aria-label": `${dimension.id} 得分`,
1731
+ "aria-valuemin": 0,
1732
+ "aria-valuemax": dimension.max === void 0 || dimension.max <= 0 ? 10 : dimension.max,
1733
+ "aria-valuenow": dimension.average
1734
+ }, (0, react.createElement)("i", { style: { width: `${Math.min(100, Math.max(0, dimension.average / (dimension.max === void 0 || dimension.max <= 0 ? 10 : dimension.max) * 100))}%` } })), (0, react.createElement)("span", null, dimension.samples === 0 ? "—" : dimension.average.toFixed(1))));
1599
1735
  const worldEntries = (entries) => entries.map((entry) => (0, react.createElement)("li", { key: entry.name }, (0, react.createElement)("b", null, entry.name), (0, react.createElement)("span", null, entry.description), entry.anchors === void 0 || entry.anchors.length === 0 ? null : (0, react.createElement)("span", { className: "nwe-v6-note" }, `锚点:${entry.anchors.join(" / ")}`)));
1600
1736
  return (0, react.createElement)("section", {
1601
1737
  className: "nwe-section",
@@ -1603,7 +1739,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1603
1739
  }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "这本书"), (0, react.createElement)("span", { className: "nwe-section-note" }, "只读投影 · 不含正文")), (0, react.createElement)("div", { className: "nwe-v6-grid" }, v6Card("bp", "书脊与蓝图", [v6Kv([
1604
1740
  ["作者", data.identity.author || "未填写"],
1605
1741
  ["简介", data.identity.description || "未填写"],
1606
- ["创建", data.identity.created_at || "—"],
1742
+ ["创建", localTime(data.identity.created_at)],
1607
1743
  ["目标", `${data.identity.targets.words_per_chapter[0]}–${data.identity.targets.words_per_chapter[1]} 字 / 章 · ${data.identity.targets.total_chapters ?? "不限"} 章`],
1608
1744
  ["主题", data.blueprint.themes.join(" / ") || "—"],
1609
1745
  ["基调 / POV", `${data.blueprint.tone || "—"} / ${data.blueprint.pov || "—"}`],
@@ -1612,12 +1748,12 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1612
1748
  ]), (0, react.createElement)("p", { className: "nwe-v6-note" }, data.blueprint.logline || data.blueprint.premise || "还没有蓝图摘要")], true), v6Card("progress", "进度与当前章", [v6Kv([
1613
1749
  ["已归档", `${data.progress.archived} / ${data.progress.target ?? "不限"} 章`],
1614
1750
  ["字数", `归档 ${data.progress.words_archived} · 含草稿 ${data.progress.words_with_draft}`],
1615
- ["剩余", data.progress.remaining_words === null ? "—" : `${data.progress.remaining_words} 字(${data.progress.remaining_words_basis})`],
1616
- ["当前章", `${data.current.chapter} · ${data.current.stage} · ${data.current.card?.title ?? "无章卡"}`],
1751
+ ["剩余", data.progress.remaining_words === null ? "—" : `${data.progress.remaining_words} 字(${remainingBasisLabel(data.progress.remaining_words_basis)})`],
1752
+ ["当前章", `${currentLabel} · ${data.current.card?.title ?? "无章卡"}`],
1617
1753
  ["轮次", `评 ${data.current.rounds.review} / 修 ${data.current.rounds.revise} / 检 ${data.current.rounds.check}`],
1618
1754
  ["正文 hash", data.current.manuscript_hash ?? "—"],
1619
1755
  ["当前字数", data.current.metrics === null ? "—" : String(data.current.metrics.total_word_count)]
1620
- ]), data.current.card === null ? null : (0, react.createElement)("div", { className: "nwe-v6-tags" }, ...data.current.card.cast.map((name) => (0, react.createElement)("span", { key: name }, name)))]), v6Card("cast", "人物与关系", [v6Kv([["角色总数", String(data.cast.total)], ["关系", `${data.relationships.total} 条 · ${Object.entries(data.relationships.types).map(([type, count]) => `${type} ${count}`).join(" / ") || "—"}`]]), (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...data.cast.top.map((person) => (0, react.createElement)("li", { key: person.name }, (0, react.createElement)("b", null, person.name), (0, react.createElement)("span", null, `${person.role} · 出场 ${person.appearances} · 最近第 ${person.last_seen} 章`))))]), v6Card("world", "世界与设定", [
1756
+ ]), data.current.card === null ? null : (0, react.createElement)("div", { className: "nwe-v6-tags" }, ...data.current.card.cast.map((name) => (0, react.createElement)("span", { key: name }, name)))]), v6Card("volumes", "卷进度", [view.book.volumes.length === 0 ? (0, react.createElement)("p", { className: "nwe-v6-note" }, "还没有卷") : (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...view.book.volumes.map((volume) => (0, react.createElement)("li", { key: `${volume.title}-${volume.range}` }, (0, react.createElement)("b", null, volume.title), (0, react.createElement)("span", null, `${volume.range} · ${volume.planned} 计划 / ${volume.archived} 归档`))))]), v6Card("cast", "人物与关系", [v6Kv([["角色总数", String(data.cast.total)], ["关系", `${data.relationships.total} 条 · ${Object.entries(data.relationships.types).map(([type, count]) => `${type} ${count}`).join(" / ") || "—"}`]]), (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...data.cast.top.map((person) => (0, react.createElement)("li", { key: person.name }, (0, react.createElement)("b", null, person.name), (0, react.createElement)("span", null, `${person.role} · 出场 ${person.appearances} · 最近第 ${person.last_seen} 章`))))]), v6Card("world", "世界与设定", [
1621
1757
  v6Kv([
1622
1758
  ["地点 / 势力", `${data.worldbook.counts.locations} / ${data.worldbook.counts.factions}`],
1623
1759
  ["规则 / 术语", `${data.worldbook.counts.rules} / ${data.worldbook.counts.glossary}`],
@@ -1633,37 +1769,522 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1633
1769
  ]), v6Card("foreshadow", "伏笔与时间线", [v6Kv([["伏笔", `未解 ${data.foreshadows.open} · 休眠 ${data.foreshadows.dormant} · 逾期 ${data.foreshadows.overdue} · 已解 ${data.foreshadows.resolved}`], ["时间线", `${data.timeline.events} 个事件 · 第 ${data.timeline.first_chapter ?? "—"}–${data.timeline.last_chapter ?? "—"} 章`]]), (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...data.foreshadows.oldest.map((item) => (0, react.createElement)("li", { key: item.id }, (0, react.createElement)("b", null, item.id), (0, react.createElement)("span", null, `${item.title} · 已 ${item.age} 章`))))]), v6Card("quality", "质量与节奏", [
1634
1770
  v6Kv([
1635
1771
  ["全书均分", `${data.quality.average.toFixed(1)} · 中位 ${data.quality.median.toFixed(1)} · ${data.quality.min}–${data.quality.max}`],
1636
- ["近十均分", data.quality.last_ten_average.toFixed(1)],
1637
- ["样本 / 评审", `${data.quality.samples} / ${data.quality.attempts}`],
1638
- ["趋势", data.quality.trend === null ? data.quality.trend_basis : `${data.quality.trend >= 0 ? "+" : ""}${data.quality.trend}`],
1639
- ["节奏", JSON.stringify(data.pacing.scene_types)],
1640
- ["钩子 / 推进", `${JSON.stringify(data.pacing.hook_strength)} / ${JSON.stringify(data.pacing.plot_advancement)}`],
1772
+ ["近十均分", data.quality.last_ten_average === null ? "—" : data.quality.last_ten_average.toFixed(1)],
1773
+ ["样本 / 评审", data.quality.samples === 0 ? "见质量页" : `${data.quality.samples} / ${data.quality.attempts}`],
1774
+ ["趋势", data.quality.trend === null ? data.quality.samples === 0 ? "见质量页" : data.quality.trend_basis === "insufficient_samples" ? "样本不足" : data.quality.trend_basis === "insufficient_history" ? "历史不足" : "最近五章对比前五章" : `${data.quality.trend >= 0 ? "+" : ""}${data.quality.trend}`],
1775
+ ["节奏", countsText(data.pacing.scene_types)],
1776
+ ["钩子 / 推进", `${countsText(data.pacing.hook_strength, hookLabel)} / ${countsText(data.pacing.plot_advancement, plotLabel)}`],
1641
1777
  ["最长 / 最短", data.pacing.longest === null ? "—" : `第 ${data.pacing.longest.chapter} 章 ${data.pacing.longest.words} 字 / 第 ${data.pacing.shortest?.chapter ?? "—"} 章 ${data.pacing.shortest?.words ?? "—"} 字`],
1642
1778
  ["返工", `评 ${data.effort.reviews} · 修 ${data.effort.revisions} · 检 ${data.effort.checks} · 回滚 ${data.effort.rollbacks}`]
1643
1779
  ]),
1644
- (0, react.createElement)("div", {
1780
+ dims.length === 0 ? (0, react.createElement)("div", { className: "nwe-v6-note" }, "质量维度请切换到「质量」页查看;概览只取轻量摘要。") : (0, react.createElement)("div", {
1645
1781
  className: "nwe-v6-bars",
1646
1782
  "aria-label": "十五维质量"
1647
1783
  }, ...dims),
1648
- (0, react.createElement)("details", null, (0, react.createElement)("summary", null, "逐章 effort"), (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...data.effort.chapters.map((chapter) => (0, react.createElement)("li", { key: chapter.num }, (0, react.createElement)("b", null, `第 ${chapter.num} 章`), (0, react.createElement)("span", null, `评 ${chapter.reviews} / 修 ${chapter.revisions} / 检 ${chapter.checks} / 回滚 ${chapter.rollbacks} · ${chapter.final_check ?? "—"}`)))))
1784
+ (0, react.createElement)("details", null, (0, react.createElement)("summary", null, "逐章 effort"), (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...data.effort.chapters.map((chapter) => (0, react.createElement)("li", { key: chapter.num }, (0, react.createElement)("b", null, `第 ${chapter.num} 章`), (0, react.createElement)("span", null, `评 ${chapter.reviews} / 修 ${chapter.revisions} / 检 ${chapter.checks} / 回滚 ${chapter.rollbacks} · ${finalCheckLabel(chapter.final_check)}`)))))
1649
1785
  ]), v6Card("health", "健康与恢复", [
1650
1786
  v6Kv([
1651
1787
  ["大纲", data.health.outline.present ? "可读" : data.health.outline.error === null ? "还没有大纲" : "损坏"],
1652
1788
  ["世界观", data.health.worldbook.present ? "可读" : data.health.worldbook.error === null ? "还没有设定" : "损坏"],
1653
- ["pending", data.health.pending === null ? "无" : `${data.health.pending.state} · ${data.health.pending.detail}`]
1789
+ ["pending", data.health.pending === null ? "无" : `${statusLabel(data.health.pending.state)} · ${data.health.pending.detail}`]
1654
1790
  ]),
1655
1791
  data.health.outline.error === null ? null : (0, react.createElement)("p", { className: "nwe-v6-note" }, data.health.outline.error),
1656
1792
  data.health.worldbook.error === null ? null : (0, react.createElement)("p", { className: "nwe-v6-note" }, data.health.worldbook.error),
1657
1793
  (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...data.health.state_errors.map((error) => (0, react.createElement)("li", { key: `${error.path}:${error.code}` }, (0, react.createElement)("b", null, error.code), (0, react.createElement)("span", null, `${error.path}: ${error.message}`))))
1658
1794
  ])));
1659
1795
  };
1796
+ const PANEL_TABS = [
1797
+ {
1798
+ id: "overview",
1799
+ label: "概览"
1800
+ },
1801
+ {
1802
+ id: "chapters",
1803
+ label: "章节"
1804
+ },
1805
+ {
1806
+ id: "characters",
1807
+ label: "人物"
1808
+ },
1809
+ {
1810
+ id: "foreshadows",
1811
+ label: "线索"
1812
+ },
1813
+ {
1814
+ id: "worldbook",
1815
+ label: "设定"
1816
+ },
1817
+ {
1818
+ id: "quality",
1819
+ label: "质量"
1820
+ },
1821
+ {
1822
+ id: "health",
1823
+ label: "健康"
1824
+ }
1825
+ ];
1826
+ const EMPTY_CHAPTER_QUERY = {
1827
+ search: "",
1828
+ status: "all",
1829
+ scene: "",
1830
+ sort: "num_desc",
1831
+ cursor: ""
1832
+ };
1833
+ const tabBar = (tab, onTab) => (0, react.createElement)("nav", {
1834
+ className: "nwe-tabs",
1835
+ role: "tablist",
1836
+ "aria-label": "网文工坊栏目"
1837
+ }, ...PANEL_TABS.map((item) => (0, react.createElement)("button", {
1838
+ key: item.id,
1839
+ type: "button",
1840
+ role: "tab",
1841
+ id: `nwe-tab-${item.id}`,
1842
+ "aria-controls": `nwe-panel-${item.id}`,
1843
+ "aria-selected": tab === item.id,
1844
+ className: `nwe-tab${tab === item.id ? " is-active" : ""}`,
1845
+ onClick: () => onTab(item.id)
1846
+ }, item.label)));
1847
+ const asRow = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1848
+ const textOf = (value, fallback = "—") => typeof value === "string" && value !== "" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
1849
+ const roleLabel = (value) => value === "protagonist" ? "主角" : value === "supporting" ? "配角" : value === "extra" ? "龙套" : textOf(value);
1850
+ const STATUS_LABELS = {
1851
+ planted: "已埋",
1852
+ advanced: "已推进",
1853
+ resolved: "已回收",
1854
+ archived: "已归档",
1855
+ draft: "草稿",
1856
+ dormant: "休眠",
1857
+ planned: "计划",
1858
+ writing: "写作中",
1859
+ done: "完成",
1860
+ open: "待处理",
1861
+ fixed: "已修复",
1862
+ waived: "已豁免",
1863
+ committed: "已提交",
1864
+ partial: "不完整",
1865
+ complete: "已完成",
1866
+ untouched: "未开始",
1867
+ recoverable: "可恢复",
1868
+ abortable: "可中止"
1869
+ };
1870
+ const statusLabel = (value) => typeof value === "string" ? STATUS_LABELS[value] ?? value : textOf(value);
1871
+ const finalCheckLabel = (value) => value === "pass" ? "通过" : value === "fail" ? "未过" : "—";
1872
+ const remainingBasisLabel = (value) => value === "actual_average" ? "按已归档章均字" : value === "minimum_target" ? "按最低目标" : value === "none" ? "无" : value;
1873
+ const countsText = (value, label = (key) => key) => {
1874
+ const row = asRow(value);
1875
+ if (row === null) return textOf(value);
1876
+ const entries = Object.entries(row).filter(([, count]) => typeof count === "number");
1877
+ return entries.length === 0 ? "—" : entries.map(([key, count]) => `${label(key)} ${count}`).join(" · ");
1878
+ };
1879
+ const hookLabel = (key) => key === "1" ? "弱" : key === "2" ? "较弱" : key === "3" ? "中" : key === "4" ? "较强" : key === "5" ? "强" : key;
1880
+ const plotLabel = (key) => key === "high" ? "高" : key === "mid" ? "中" : key === "low" ? "低" : key;
1881
+ const sectionKv = (row) => {
1882
+ const fields = [
1883
+ ["角色", roleLabel(row.role)],
1884
+ ["状态", statusLabel(row.status)],
1885
+ ["首次出场", textOf(row.first_appearance ?? row.first)],
1886
+ ["最近出场", textOf(row.last)],
1887
+ ["出场次数", textOf(row.appearances)],
1888
+ ["类型", textOf(row.type)],
1889
+ ["地点", textOf(row.location)],
1890
+ ["双方", row.a === void 0 ? "—" : `${textOf(row.a)} ↔ ${textOf(row.b)}`],
1891
+ ["别名", textOf(row.alias)],
1892
+ ["数值", textOf(row.value)],
1893
+ ["单位", textOf(row.unit)],
1894
+ ["描述", textOf(row.description)],
1895
+ ["锚点", textOf(row.anchor)],
1896
+ ["容忍", textOf(row.tolerance)],
1897
+ ["备注", textOf(row.note)],
1898
+ ["推进记录", Array.isArray(row.advance_log) ? `${row.advance_log.length} 条` : "—"],
1899
+ ["成长", Array.isArray(row.growth) ? `${row.growth.length} 条` : "—"],
1900
+ ["当前状态", textOf(row.current_state)],
1901
+ ["人物档案", textOf(row.profile)],
1902
+ ["章龄", textOf(row.age)],
1903
+ ["术语", textOf(row.term)],
1904
+ ["释义", textOf(row.definition)],
1905
+ ["时间", textOf(row.time)],
1906
+ ["事件", textOf(row.event)]
1907
+ ];
1908
+ const used = /* @__PURE__ */ new Set([
1909
+ "role",
1910
+ "status",
1911
+ "first_appearance",
1912
+ "first",
1913
+ "last",
1914
+ "appearances",
1915
+ "type",
1916
+ "location",
1917
+ "a",
1918
+ "b",
1919
+ "alias",
1920
+ "value",
1921
+ "unit",
1922
+ "description",
1923
+ "anchor",
1924
+ "tolerance",
1925
+ "note",
1926
+ "advance_log",
1927
+ "growth"
1928
+ ]);
1929
+ const extraLabels = {
1930
+ average: "平均",
1931
+ samples: "可读评审",
1932
+ median: "中位",
1933
+ min: "最小",
1934
+ max: "最大",
1935
+ trend: "趋势",
1936
+ attempts: "评审轮次",
1937
+ reviews: "评",
1938
+ revisions: "修",
1939
+ checks: "检",
1940
+ rollbacks: "回滚",
1941
+ chapters: "章节",
1942
+ words: "字数",
1943
+ total: "总数",
1944
+ present: "存在",
1945
+ error: "错误",
1946
+ path: "路径",
1947
+ code: "代码",
1948
+ message: "信息",
1949
+ chapter: "章",
1950
+ field: "字段",
1951
+ planned: "计划",
1952
+ actual: "实际",
1953
+ state: "状态",
1954
+ detail: "详情",
1955
+ txid: "事务",
1956
+ num: "章号",
1957
+ scene_type: "场景",
1958
+ goal: "目标",
1959
+ conflict: "冲突",
1960
+ hook: "钩子",
1961
+ notes: "备注",
1962
+ story_time: "故事时间",
1963
+ since: "始于",
1964
+ word_count: "字数",
1965
+ score: "评分",
1966
+ archived_at: "归档时间",
1967
+ characters: "人物",
1968
+ evolution: "演变"
1969
+ };
1970
+ for (const [key, value] of Object.entries(row)) {
1971
+ if (used.has(key) || key === "title" || key === "name" || key === "id") continue;
1972
+ const label = extraLabels[key];
1973
+ if (key === "archived_at") {
1974
+ fields.push([label, localTime(typeof value === "string" ? value : null)]);
1975
+ continue;
1976
+ }
1977
+ if (label !== void 0 && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")) fields.push([label, textOf(value)]);
1978
+ }
1979
+ const visible = fields.filter(([, value]) => value !== "—");
1980
+ return (0, react.createElement)("dl", { className: "nwe-kv" }, ...visible.flatMap(([label, value]) => [(0, react.createElement)("dt", { key: `dt-${label}` }, label), (0, react.createElement)("dd", { key: `dd-${label}` }, value)]));
1981
+ };
1982
+ const sectionList = (title, items) => {
1983
+ if (!Array.isArray(items) || items.length === 0) return (0, react.createElement)("div", { className: "nwe-empty" }, `${title}:暂无数据`);
1984
+ return (0, react.createElement)("div", { className: "nwe-section-list" }, ...items.map((item, index) => {
1985
+ const row = asRow(item);
1986
+ const label = row === null ? String(item) : textOf(row.title ?? row.name ?? row.id ?? row.alias ?? row.a ?? row.term ?? row.event, `第 ${index + 1} 条`);
1987
+ return (0, react.createElement)("details", {
1988
+ className: "nwe-data-row",
1989
+ key: `${label}-${index}`
1990
+ }, (0, react.createElement)("summary", null, label), row === null ? (0, react.createElement)("div", { className: "nwe-data-json" }, String(item)) : sectionKv(row));
1991
+ }));
1992
+ };
1993
+ const sectionBlock = (title, items, key) => {
1994
+ const list = Array.isArray(items) ? items : [];
1995
+ if (list.length === 0) return null;
1996
+ return (0, react.createElement)("section", {
1997
+ className: "nwe-subsection",
1998
+ key
1999
+ }, (0, react.createElement)("h3", null, `${title}(${list.length})`), sectionList(title, list));
2000
+ };
2001
+ const worldbookSection = (items) => {
2002
+ const row = asRow(items);
2003
+ if (row === null) return sectionList("设定", items);
2004
+ const groups = [
2005
+ ["rules", "规则"],
2006
+ ["factions", "势力"],
2007
+ ["locations", "地点"],
2008
+ ["characters", "人物小传"],
2009
+ ["glossary", "术语"],
2010
+ ["backstory", "背景"],
2011
+ ["facts", "数值事实"],
2012
+ ["themes", "主题"],
2013
+ ["outline_cards", "章卡"],
2014
+ ["drift", "大纲漂移"]
2015
+ ];
2016
+ const ledger = asRow(row.ledger);
2017
+ const blueprint = asRow(row.blueprint);
2018
+ const volumes = Array.isArray(row.volumes) ? row.volumes : [];
2019
+ const intro = [
2020
+ typeof row.premise === "string" && row.premise !== "" ? {
2021
+ title: "前提",
2022
+ description: row.premise
2023
+ } : null,
2024
+ typeof row.setting === "string" && row.setting !== "" ? {
2025
+ title: "设定",
2026
+ description: row.setting
2027
+ } : null,
2028
+ blueprint === null ? null : {
2029
+ title: "蓝图",
2030
+ description: [
2031
+ Array.isArray(blueprint.themes) ? `主题 ${blueprint.themes.join(" / ")}` : "",
2032
+ typeof blueprint.tone === "string" ? `基调 ${blueprint.tone}` : "",
2033
+ typeof blueprint.pov === "string" ? `POV ${blueprint.pov}` : "",
2034
+ typeof blueprint.ending === "string" ? `结局 ${blueprint.ending}` : ""
2035
+ ].filter((part) => part !== "").join(" · ")
2036
+ },
2037
+ volumes.length === 0 ? null : {
2038
+ title: "卷",
2039
+ description: volumes.map((volume) => {
2040
+ const item = asRow(volume);
2041
+ return item === null ? "" : `${textOf(item.title)} ${textOf(item.start_chapter)}–${textOf(item.end_chapter)}`;
2042
+ }).filter((part) => part !== "").join(" / ")
2043
+ }
2044
+ ].filter((entry) => entry !== null);
2045
+ const ledgerGroups = [
2046
+ ["locations", "台账地点"],
2047
+ ["abilities", "台账能力"],
2048
+ ["artifacts", "台账物品"],
2049
+ ["factions", "台账势力"],
2050
+ ["rules", "台账规则"]
2051
+ ];
2052
+ const blocks = [
2053
+ ...intro.length === 0 ? [] : [sectionBlock("蓝图", intro, "blueprint")],
2054
+ ...groups.map(([key, label]) => sectionBlock(label, row[key], key)),
2055
+ ...ledgerGroups.map(([key, label]) => sectionBlock(label, ledger?.[key], `ledger-${key}`))
2056
+ ].filter((block) => block !== null);
2057
+ return blocks.length === 0 ? (0, react.createElement)("div", { className: "nwe-empty" }, "设定:暂无数据") : (0, react.createElement)("div", { className: "nwe-section-list" }, ...blocks);
2058
+ };
2059
+ const qualitySection = (items, chapter = 0, detail = null, onDetail = () => void 0) => {
2060
+ const row = asRow(items);
2061
+ if (row === null) return sectionList("质量", items);
2062
+ const quality = asRow(row.quality);
2063
+ const dimensions = Array.isArray(row.dimensions) ? row.dimensions : [];
2064
+ const summary = quality === null || Number(quality.samples ?? 0) === 0 ? (0, react.createElement)("div", { className: "nwe-empty" }, "质量:暂无评审") : (0, react.createElement)("div", { className: "nwe-quality-summary" }, (0, react.createElement)("span", null, `均分 ${textOf(quality.average)}`), (0, react.createElement)("span", null, `可读评审 ${textOf(quality.samples)}`), (0, react.createElement)("span", null, `中位 ${textOf(quality.median)}`), (0, react.createElement)("span", null, `区间 ${textOf(quality.min)}–${textOf(quality.max)}`));
2065
+ const bars = dimensions.map((entry, index) => {
2066
+ const dimension = asRow(entry);
2067
+ if (dimension === null) return null;
2068
+ const average = Number(dimension.average);
2069
+ const max = Number(dimension.max);
2070
+ const id = textOf(dimension.id, `维度 ${index + 1}`);
2071
+ const percent = max > 0 ? Math.max(0, Math.min(100, average / max * 100)) : 0;
2072
+ return (0, react.createElement)("div", {
2073
+ className: "nwe-dim",
2074
+ key: id
2075
+ }, (0, react.createElement)("span", null, id), (0, react.createElement)("div", {
2076
+ className: "nwe-v6-bar",
2077
+ role: "progressbar",
2078
+ "aria-label": `${id} 得分`,
2079
+ "aria-valuemin": 0,
2080
+ "aria-valuemax": max,
2081
+ "aria-valuenow": Number.isFinite(average) ? average : 0
2082
+ }, (0, react.createElement)("span", null, (0, react.createElement)("i", { style: { width: `${percent}%` } }))), (0, react.createElement)("span", null, `${Number.isFinite(average) ? average : "—"} / ${max > 0 ? max : "—"}`));
2083
+ });
2084
+ const pacing = asRow(row.pacing);
2085
+ const longest = asRow(pacing?.longest);
2086
+ const shortest = asRow(pacing?.shortest);
2087
+ const pacingRows = pacing === null ? [] : [
2088
+ {
2089
+ title: "节奏",
2090
+ description: countsText(pacing.scene_types)
2091
+ },
2092
+ {
2093
+ title: "钩子 / 推进",
2094
+ description: `${countsText(pacing.hook_strength, hookLabel)} / ${countsText(pacing.plot_advancement, plotLabel)}`
2095
+ },
2096
+ {
2097
+ title: "最长 / 最短",
2098
+ description: pacing.longest === null ? "—" : `第 ${textOf(longest?.chapter)} 章 ${textOf(longest?.words)} 字 / 第 ${textOf(shortest?.chapter)} 章 ${textOf(shortest?.words)} 字`
2099
+ }
2100
+ ];
2101
+ const extras = [pacingRows.length === 0 ? null : sectionBlock("节奏", pacingRows, "pacing"), sectionBlock("投入", row.effort === null || row.effort === void 0 ? [] : [row.effort], "effort")].filter((block) => block !== null);
2102
+ return (0, react.createElement)("div", { className: "nwe-section-list" }, summary, chapter > 0 ? (0, react.createElement)("button", {
2103
+ type: "button",
2104
+ className: "nwe-refresh",
2105
+ onClick: () => onDetail(chapter)
2106
+ }, "查看本章质量详情") : null, ...bars, ...extras, qualityDetailPanel(detail, onDetail));
2107
+ };
2108
+ const healthSection = (items) => {
2109
+ const row = asRow(items);
2110
+ if (row === null) return sectionList("健康", items);
2111
+ const flags = ["outline", "worldbook"].map((key) => {
2112
+ const value = asRow(row[key]);
2113
+ const present = value?.present === true;
2114
+ const existed = value?.file_existed === true;
2115
+ return (0, react.createElement)("span", {
2116
+ className: `nwe-badge ${present ? "is-ok" : "is-bad"}`,
2117
+ key
2118
+ }, `${key === "outline" ? "大纲" : "设定"}:${present ? "可读" : existed ? "损坏" : "缺失"}`);
2119
+ });
2120
+ const stateErrors = Array.isArray(row.state_errors) ? row.state_errors : [];
2121
+ const drift = Array.isArray(row.drift) ? row.drift : [];
2122
+ const blocks = [sectionBlock("状态错误", stateErrors, "state-errors"), sectionBlock("大纲漂移", drift, "drift")].filter((block) => block !== null);
2123
+ return (0, react.createElement)("div", { className: "nwe-section-list" }, (0, react.createElement)("div", { className: "nwe-health-flags" }, ...flags), ...blocks);
2124
+ };
2125
+ const chapterToolbar = (query, onQuery) => {
2126
+ return (0, react.createElement)("div", {
2127
+ className: "nwe-chapter-toolbar",
2128
+ role: "search",
2129
+ "aria-label": "筛选章节"
2130
+ }, (0, react.createElement)("label", null, "搜索标题", (0, react.createElement)("input", {
2131
+ type: "search",
2132
+ value: query.search,
2133
+ placeholder: "输入章节标题",
2134
+ onChange: (event) => onQuery({
2135
+ search: event.target.value,
2136
+ cursor: ""
2137
+ })
2138
+ })), (0, react.createElement)("label", null, "状态", (0, react.createElement)("select", {
2139
+ value: query.status,
2140
+ onChange: (event) => onQuery({
2141
+ status: event.target.value,
2142
+ cursor: ""
2143
+ })
2144
+ }, (0, react.createElement)("option", { value: "all" }, "全部"), (0, react.createElement)("option", { value: "archived" }, "已归档"), (0, react.createElement)("option", { value: "draft" }, "草稿"))), (0, react.createElement)("label", null, "场景", (0, react.createElement)("select", {
2145
+ value: query.scene,
2146
+ onChange: (event) => onQuery({
2147
+ scene: event.target.value,
2148
+ cursor: ""
2149
+ })
2150
+ }, (0, react.createElement)("option", { value: "" }, "全部"), ...[
2151
+ "战斗/动作章",
2152
+ "日常过渡章",
2153
+ "情感爆发章",
2154
+ "纯叙事章"
2155
+ ].map((scene) => (0, react.createElement)("option", {
2156
+ value: scene,
2157
+ key: scene
2158
+ }, scene)))), (0, react.createElement)("label", null, "排序", (0, react.createElement)("select", {
2159
+ value: query.sort,
2160
+ onChange: (event) => onQuery({
2161
+ sort: event.target.value,
2162
+ cursor: ""
2163
+ })
2164
+ }, (0, react.createElement)("option", { value: "num_desc" }, "章号倒序"), (0, react.createElement)("option", { value: "num_asc" }, "章号正序"))));
2165
+ };
2166
+ const chapterDetailPanel = (detail, onClose) => {
2167
+ if (detail === null) return null;
2168
+ const payload = detail.section.payload;
2169
+ const items = asRow(payload?.items);
2170
+ const manuscript = typeof items?.manuscript === "string" ? items.manuscript : null;
2171
+ const plan = typeof items?.plan === "string" ? items.plan : null;
2172
+ const worldPlan = typeof items?.world_plan === "string" ? items.world_plan : null;
2173
+ const detailRow = asRow(items?.row);
2174
+ const rawSummaryEvents = items?.summary_events ?? detailRow?.summary_events;
2175
+ const summaryEvents = Array.isArray(rawSummaryEvents) ? rawSummaryEvents.filter((event) => typeof event === "string") : [];
2176
+ return (0, react.createElement)("section", {
2177
+ className: "nwe-chapter-detail",
2178
+ "aria-label": `第 ${detail.chapter} 章详情`
2179
+ }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h3", { className: "nwe-section-title" }, `第 ${detail.chapter} 章详情`), (0, react.createElement)("button", {
2180
+ type: "button",
2181
+ className: "nwe-refresh",
2182
+ onClick: () => onClose(null)
2183
+ }, "收起")), detail.section.loading && payload === null ? (0, react.createElement)("div", { className: "nwe-section-note" }, "正在读取章节详情…") : detail.section.error !== null ? (0, react.createElement)("div", { className: "nwe-empty" }, `章节详情读取失败:${detail.section.error}`) : (0, react.createElement)(react.Fragment, null, manuscript === null ? (0, react.createElement)("div", { className: "nwe-empty" }, "正文尚未写入。") : (0, react.createElement)("details", { open: true }, (0, react.createElement)("summary", null, `正文 · ${Array.from(manuscript).length} 字`), (0, react.createElement)("pre", { className: "nwe-detail-text" }, manuscript)), plan === null ? null : (0, react.createElement)("details", null, (0, react.createElement)("summary", null, "归档 plan"), (0, react.createElement)("pre", { className: "nwe-detail-text" }, plan)), worldPlan === null ? null : (0, react.createElement)("details", null, (0, react.createElement)("summary", null, "世界计划"), (0, react.createElement)("pre", { className: "nwe-detail-text" }, worldPlan)), summaryEvents.length === 0 ? null : (0, react.createElement)("details", null, (0, react.createElement)("summary", null, "本章摘要"), (0, react.createElement)("ul", { className: "nwe-v6-list" }, ...summaryEvents.map((event, index) => (0, react.createElement)("li", { key: `${index}-${event}` }, event))))));
2184
+ };
2185
+ const qualityDetailPanel = (detail, onClose) => {
2186
+ if (detail === null) return null;
2187
+ const payload = detail.section.payload;
2188
+ const items = asRow(payload?.items);
2189
+ const review = typeof items?.review === "string" ? items.review : null;
2190
+ const check = typeof items?.check === "string" ? items.check : null;
2191
+ return (0, react.createElement)("section", {
2192
+ className: "nwe-chapter-detail",
2193
+ "aria-label": `第 ${detail.chapter} 章质量详情`
2194
+ }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h3", { className: "nwe-section-title" }, `第 ${detail.chapter} 章质量详情`), (0, react.createElement)("button", {
2195
+ type: "button",
2196
+ className: "nwe-refresh",
2197
+ onClick: () => onClose(null)
2198
+ }, "收起")), detail.section.loading && payload === null ? (0, react.createElement)("div", { className: "nwe-section-note" }, "正在读取质量详情…") : detail.section.error !== null ? (0, react.createElement)("div", { className: "nwe-empty" }, `质量详情读取失败:${detail.section.error}`) : review === null && check === null ? (0, react.createElement)("div", { className: "nwe-empty" }, "还没有评审或检查文档。") : (0, react.createElement)(react.Fragment, null, review === null ? null : (0, react.createElement)("details", { open: true }, (0, react.createElement)("summary", null, "评审 review"), (0, react.createElement)("pre", { className: "nwe-detail-text" }, review)), check === null ? null : (0, react.createElement)("details", null, (0, react.createElement)("summary", null, "检查 check"), (0, react.createElement)("pre", { className: "nwe-detail-text" }, check))));
2199
+ };
2200
+ const chapterTable = (section, view, query, onQuery, detail = null, onDetail = () => void 0) => {
2201
+ if (section === null || section.loading && section.payload === null) return chaptersSection(view);
2202
+ if (section.error !== null && section.payload === null) return (0, react.createElement)(react.Fragment, null, (0, react.createElement)("div", { className: "nwe-empty" }, `章节读取失败:${section.error}`), chaptersSection(view));
2203
+ const payload = section.payload;
2204
+ const items = payload?.items;
2205
+ if (!Array.isArray(items)) return chaptersSection(view);
2206
+ const rows = items.map(asRow).filter((row) => row !== null);
2207
+ const total = payload?.total ?? rows.length;
2208
+ const cursor = query.cursor;
2209
+ return (0, react.createElement)("section", {
2210
+ className: "nwe-chapters-section",
2211
+ "aria-label": "章节表"
2212
+ }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, payload?.truncated === true && rows.length < total ? `显示 ${rows.length} / 共 ${total}` : payload?.truncated === true ? `共 ${total} 章 · 部分文本已截断` : `共 ${total} 章`)), rows.length === 0 ? (0, react.createElement)("div", { className: "nwe-empty" }, "没有匹配的章节。") : (0, react.createElement)("div", { className: "nwe-table-wrap" }, (0, react.createElement)("table", { className: "nwe-chapter-table" }, (0, react.createElement)("thead", null, (0, react.createElement)("tr", null, ...[
2213
+ "章",
2214
+ "标题",
2215
+ "状态",
2216
+ "字数",
2217
+ "评分",
2218
+ "场景",
2219
+ "终检",
2220
+ "归档",
2221
+ "详情"
2222
+ ].map((label) => (0, react.createElement)("th", {
2223
+ key: label,
2224
+ scope: "col"
2225
+ }, label)))), (0, react.createElement)("tbody", null, ...rows.map((row, index) => {
2226
+ const num = textOf(row.num, String(index + 1));
2227
+ const score = typeof row.score === "number" ? String(row.score) : "—";
2228
+ return (0, react.createElement)("tr", { key: `${num}-${index}` }, (0, react.createElement)("td", { className: "is-num" }, `第 ${num} 章`), (0, react.createElement)("td", { className: "nwe-table-title" }, textOf(row.title)), (0, react.createElement)("td", null, row.status === "draft" ? "草稿" : "已归档"), (0, react.createElement)("td", null, textOf(row.word_count)), (0, react.createElement)("td", null, score), (0, react.createElement)("td", null, textOf(row.scene_type)), (0, react.createElement)("td", null, finalCheckLabel(row.final_check)), (0, react.createElement)("td", null, localTime(typeof row.archived_at === "string" ? row.archived_at : null)), (0, react.createElement)("td", null, (0, react.createElement)("button", {
2229
+ type: "button",
2230
+ className: "nwe-refresh",
2231
+ onClick: () => onDetail(Number(num) || index + 1)
2232
+ }, "详情")));
2233
+ })))), (0, react.createElement)("div", { className: "nwe-pager" }, (0, react.createElement)("span", null, payload?.next_cursor !== null && payload?.next_cursor !== void 0 ? "已截断,可继续翻页" : payload?.truncated === true ? "部分文本已截断" : "已显示全部"), (0, react.createElement)("span", null, cursor === "" ? null : (0, react.createElement)("button", {
2234
+ type: "button",
2235
+ className: "nwe-refresh",
2236
+ onClick: () => onQuery({ cursor: "" })
2237
+ }, "回到第一页"), payload?.next_cursor === null || payload?.next_cursor === void 0 ? null : (0, react.createElement)("button", {
2238
+ type: "button",
2239
+ className: "nwe-refresh",
2240
+ onClick: () => onQuery({ cursor: String(payload.next_cursor) })
2241
+ }, "下一页"))), chapterDetailPanel(detail, onDetail));
2242
+ };
2243
+ const leadsSection = (items) => {
2244
+ const row = asRow(items);
2245
+ if (row === null) return sectionList("线索", items);
2246
+ const blocks = [
2247
+ sectionBlock("伏笔", row.foreshadows, "foreshadows"),
2248
+ sectionBlock("时间线", row.timeline, "timeline"),
2249
+ sectionBlock("关系", row.relationships, "relationships")
2250
+ ].filter((block) => block !== null);
2251
+ return blocks.length === 0 ? (0, react.createElement)("div", { className: "nwe-empty" }, "线索:暂无数据") : (0, react.createElement)("div", { className: "nwe-section-list" }, ...blocks);
2252
+ };
2253
+ const sectionMeta = (section) => {
2254
+ const payload = section.payload;
2255
+ if (payload === null || payload.total === void 0) return null;
2256
+ const returned = payload.returned ?? 0;
2257
+ const text = payload.truncated !== true ? `共 ${payload.total}` : returned < payload.total ? `显示 ${returned} / 共 ${payload.total}` : `共 ${payload.total} · 部分文本已截断`;
2258
+ return (0, react.createElement)("div", {
2259
+ className: "nwe-section-note",
2260
+ role: "status"
2261
+ }, text);
2262
+ };
2263
+ const sectionPanel = (tab, section, search = "", onSearch = () => void 0, chapter = 0, qualityDetail = null, onQualityDetail = () => void 0) => {
2264
+ if (section === null || section.loading && section.payload === null) return (0, react.createElement)("div", { className: "nwe-section-note" }, "正在读取栏目…");
2265
+ if (section.error !== null && section.payload === null) return (0, react.createElement)("div", { className: "nwe-empty" }, `栏目读取失败:${section.error}`);
2266
+ const items = section.payload?.items;
2267
+ const searchLabel = tab === "characters" ? "搜索人物" : tab === "foreshadows" ? "搜索线索" : tab === "worldbook" ? "搜索设定" : "";
2268
+ const withSearch = (content) => (0, react.createElement)(react.Fragment, null, (0, react.createElement)("label", { className: "nwe-section-search" }, searchLabel, (0, react.createElement)("input", {
2269
+ type: "search",
2270
+ value: search,
2271
+ placeholder: "输入关键词",
2272
+ onChange: (event) => onSearch(event.target.value)
2273
+ })), sectionMeta(section), content);
2274
+ if (tab === "characters") return withSearch(sectionList("人物", items));
2275
+ if (tab === "foreshadows") return withSearch(leadsSection(items));
2276
+ if (tab === "worldbook") return withSearch(worldbookSection(items));
2277
+ if (tab === "quality") return qualitySection(items, chapter, qualityDetail, onQualityDetail);
2278
+ if (tab === "health") return healthSection(items);
2279
+ return sectionList("数据", Array.isArray(items) ? items : [items]);
2280
+ };
1660
2281
  /**
1661
2282
  * Pure view: given a view model, return the element tree. Keeping the hooks in
1662
2283
  * `NovelPanel` makes every visual state renderable without a browser.
1663
2284
  */
1664
- function panelElement(view, loading, onRefresh, onSelectProject = () => void 0) {
2285
+ function panelElement(view, loading, onRefresh, onSelectProject = () => void 0, tab = "overview", onTab = () => void 0, section = null, chapterQuery = EMPTY_CHAPTER_QUERY, onChapterQuery = () => void 0, sectionSearch = "", onSectionSearch = () => void 0, chapterDetail = null, onChapterDetail = () => void 0, qualityDetail = null, onQualityDetail = () => void 0) {
1665
2286
  const ready = view.state === "ready";
1666
- const selectable = ready || view.state === "choose" || view.state === "missing" || view.state === "loading" && view.projects.length > 0;
2287
+ const selectable = view.projects.length > 0 || ready || view.state === "choose" || view.state === "missing" || view.state === "error" || view.state === "outdated" || view.state === "empty";
1667
2288
  return (0, react.createElement)("section", {
1668
2289
  "data-plugin": "dsh-novel-write-engine",
1669
2290
  className: "nwe-root",
@@ -1681,14 +2302,29 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1681
2302
  }, refreshIcon(), loading ? "刷新中…" : "刷新"))), ready && view.damaged ? (0, react.createElement)("div", {
1682
2303
  className: "nwe-damaged",
1683
2304
  role: "status"
1684
- }, "项目定义不可读;先用 novel_state 查看,再用 novel_amend 按 next 修复。面板不会把它伪装成正常项目。") : null, ready && view.nextTitle !== "" ? nextCard(view) : null, ready ? (0, react.createElement)("div", {
2305
+ }, "项目定义不可读;先用 novel_state 查看,再用 novel_amend 按 next 修复。面板不会把它伪装成正常项目。") : null, ready && view.nextTitle !== "" ? nextCard(view) : null, ready ? tabBar(tab, onTab) : null, ready && tab === "overview" ? (0, react.createElement)("div", {
1685
2306
  className: "nwe-stats",
1686
2307
  role: "group",
1687
2308
  "aria-label": "项目统计"
1688
- }, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), "字"), stat("全书均分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, ready ? bookDataSection(view) : null, view.state === "loading" ? loadingState() : null, view.state === "choose" ? choosePanel(view, onSelectProject) : null, view.state === "missing" ? missingPanel(view) : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? (0, react.createElement)("div", { className: "nwe-desk" }, (0, react.createElement)("div", { className: "nwe-desk-main" }, (0, react.createElement)("div", { className: "nwe-desk-heading" }, (0, react.createElement)("span", { className: "nwe-desk-kicker" }, "当前一章"), (0, react.createElement)("span", { className: "nwe-desk-note" }, view.stageLabel)), phases(view), blockingSection(view), advisoriesSection(view), pendingSection(view), legacySection(view), chaptersSection(view)), (0, react.createElement)("aside", {
2309
+ }, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), (view.stats.draftWords ?? 0) > 0 ? `字(归档 ${(view.stats.archivedWords ?? 0).toLocaleString("zh-CN")} + 草稿 ${view.stats.draftWords.toLocaleString("zh-CN")})` : "字"), stat("全书均分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, ready && tab === "overview" ? bookDataSection(view) ?? bookSection(view) : null, view.state === "loading" ? loadingState() : null, view.state === "choose" ? choosePanel(view, onSelectProject) : null, view.state === "missing" ? missingPanel(view) : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready && tab === "overview" ? (0, react.createElement)("div", {
2310
+ className: "nwe-desk",
2311
+ role: "tabpanel",
2312
+ id: "nwe-panel-overview",
2313
+ "aria-labelledby": "nwe-tab-overview"
2314
+ }, (0, react.createElement)("div", { className: "nwe-desk-main" }, (0, react.createElement)("div", { className: "nwe-desk-heading" }, (0, react.createElement)("span", { className: "nwe-desk-kicker" }, "当前一章"), (0, react.createElement)("span", { className: "nwe-desk-note" }, view.stageLabel)), phases(view), blockingSection(view), advisoriesSection(view), pendingSection(view), legacySection(view), chaptersSection(view)), (0, react.createElement)("aside", {
1689
2315
  className: "nwe-desk-side",
1690
2316
  "aria-label": "全书与活动"
1691
- }, bookSection(view), activitySection(view))) : null, footer());
2317
+ }, activitySection(view))) : null, ready && tab === "chapters" ? (0, react.createElement)("div", {
2318
+ className: "nwe-desk",
2319
+ role: "tabpanel",
2320
+ id: "nwe-panel-chapters",
2321
+ "aria-labelledby": "nwe-tab-chapters"
2322
+ }, (0, react.createElement)("div", { className: "nwe-desk-main" }, blockingSection(view), chapterToolbar(chapterQuery, onChapterQuery), chapterTable(section, view, chapterQuery, onChapterQuery, chapterDetail, onChapterDetail))) : null, ready && tab !== "overview" && tab !== "chapters" ? (0, react.createElement)("section", {
2323
+ className: "nwe-section nwe-tab-panel",
2324
+ role: "tabpanel",
2325
+ id: `nwe-panel-${tab}`,
2326
+ "aria-labelledby": `nwe-tab-${tab}`
2327
+ }, tab === "health" ? (0, react.createElement)(react.Fragment, null, blockingSection(view), advisoriesSection(view), pendingSection(view), legacySection(view)) : null, sectionPanel(tab, section, sectionSearch, onSectionSearch, view.chapter, qualityDetail, onQualityDetail)) : null, footer(view, onRefresh));
1692
2328
  }
1693
2329
  const PANEL_ROOT_KEY = "nwe.panel-root.v1";
1694
2330
  const storage = () => globalThis.localStorage;
@@ -1710,22 +2346,42 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1710
2346
  };
1711
2347
  const panelPath = (root) => {
1712
2348
  const base = PANEL_ROUTE.replace(/^\/+/, "");
1713
- return root === null ? base : `${base}?root=${encodeURIComponent(root)}`;
2349
+ return root === null ? `${base}?lean=1` : `${base}?root=${encodeURIComponent(root)}&lean=1`;
1714
2350
  };
1715
2351
  /** Stateful wrapper: fetch once, refresh on demand and after returning to the tab. */
1716
2352
  function NovelPanel() {
1717
2353
  const [view, setView] = (0, react.useState)(() => buildView(null));
1718
2354
  const [loading, setLoading] = (0, react.useState)(true);
1719
2355
  const [switching, setSwitching] = (0, react.useState)(false);
2356
+ const [sync, setSync] = (0, react.useState)({
2357
+ lastSync: null,
2358
+ syncError: null
2359
+ });
2360
+ const [tab, setTab] = (0, react.useState)("overview");
2361
+ const [section, setSection] = (0, react.useState)(null);
2362
+ const [chapterQuery, setChapterQuery] = (0, react.useState)(EMPTY_CHAPTER_QUERY);
2363
+ const [sectionSearch, setSectionSearch] = (0, react.useState)("");
2364
+ const [chapterDetail, setChapterDetail] = (0, react.useState)(null);
2365
+ const [qualityDetail, setQualityDetail] = (0, react.useState)(null);
2366
+ const detailController = (0, react.useRef)(null);
2367
+ const [revisionTick, setRevisionTick] = (0, react.useState)(0);
2368
+ const syncFailures = (0, react.useRef)(0);
2369
+ const nextSyncAt = (0, react.useRef)(0);
1720
2370
  const [selectedRoot, setSelectedRoot] = (0, react.useState)(() => readStoredRoot());
1721
2371
  const alive = (0, react.useRef)(true);
1722
2372
  const requestId = (0, react.useRef)(0);
1723
2373
  const visibleRequest = (0, react.useRef)(0);
1724
2374
  const controllerRef = (0, react.useRef)(null);
1725
2375
  const queryRootRef = (0, react.useRef)(selectedRoot);
2376
+ const sectionEtagRef = (0, react.useRef)(null);
2377
+ const bundleEtagRef = (0, react.useRef)(null);
2378
+ const lastSilentAt = (0, react.useRef)(0);
2379
+ const quietPolls = (0, react.useRef)(0);
1726
2380
  const loadRef = (0, react.useRef)(async () => void 0);
1727
2381
  const load = (0, react.useCallback)(async (silent = false, root = queryRootRef.current) => {
1728
2382
  if (silent && visibleRequest.current !== 0) return;
2383
+ if (silent && Date.now() - lastSilentAt.current < 1e3) return;
2384
+ if (silent) lastSilentAt.current = Date.now();
1729
2385
  const id = ++requestId.current;
1730
2386
  controllerRef.current?.abort();
1731
2387
  const controller = new AbortController();
@@ -1739,8 +2395,36 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1739
2395
  const anySignal = timeout === void 0 ? controller.signal : typeof any === "function" ? any([controller.signal, timeout]) : controller.signal;
1740
2396
  const timeoutTimer = timeout === void 0 || typeof any !== "function" ? setTimeout(() => controller.abort(), 1e4) : void 0;
1741
2397
  try {
1742
- const result = await fetchPanel(fetch, panelPath(root), anySignal);
2398
+ const rootKey = root ?? "";
2399
+ const cachedBundleEtag = bundleEtagRef.current?.root === rootKey ? bundleEtagRef.current.etag : void 0;
2400
+ const result = await fetchPanel(fetch, panelPath(root), anySignal, cachedBundleEtag);
1743
2401
  if (!alive.current || id !== requestId.current) return;
2402
+ if (result.notModified === true) {
2403
+ syncFailures.current = 0;
2404
+ quietPolls.current += 1;
2405
+ nextSyncAt.current = Date.now() + (quietPolls.current >= 2 ? 6e4 : 3e4);
2406
+ setSync({
2407
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
2408
+ syncError: null
2409
+ });
2410
+ return;
2411
+ }
2412
+ if (result.error === null) {
2413
+ syncFailures.current = 0;
2414
+ quietPolls.current = 0;
2415
+ nextSyncAt.current = 0;
2416
+ setSync({
2417
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
2418
+ syncError: null
2419
+ });
2420
+ } else {
2421
+ syncFailures.current += 1;
2422
+ nextSyncAt.current = Date.now() + (syncFailures.current === 1 ? 3e4 : 6e4);
2423
+ setSync((previous) => ({
2424
+ ...previous,
2425
+ syncError: result.error
2426
+ }));
2427
+ }
1744
2428
  const payload = result.payload;
1745
2429
  if (payload !== null && payload.missing?.reason === "not_registered" && root !== null) {
1746
2430
  queryRootRef.current = null;
@@ -1749,7 +2433,14 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1749
2433
  await loadRef.current(false, null);
1750
2434
  return;
1751
2435
  }
1752
- if (payload !== null) setSelectedRoot(payload.selection.root);
2436
+ if (payload !== null) {
2437
+ if (payload.etag !== void 0) bundleEtagRef.current = {
2438
+ root: rootKey,
2439
+ etag: payload.etag
2440
+ };
2441
+ setSelectedRoot(payload.selection.root);
2442
+ setRevisionTick((tick) => tick + 1);
2443
+ }
1753
2444
  setView((previous) => {
1754
2445
  const next = buildView(payload, result.error);
1755
2446
  if (silent && result.error !== null && previous.projects.length > 0) return previous;
@@ -1765,11 +2456,81 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1765
2456
  }
1766
2457
  }, []);
1767
2458
  loadRef.current = load;
2459
+ (0, react.useEffect)(() => {
2460
+ if (tab === "overview" || view.state !== "ready") {
2461
+ setSection(null);
2462
+ return;
2463
+ }
2464
+ const root = queryRootRef.current;
2465
+ const base = panelPath(root);
2466
+ const params = new URLSearchParams({ section: tab });
2467
+ if (tab === "chapters") {
2468
+ if (chapterQuery.search !== "") params.set("search", chapterQuery.search);
2469
+ if (chapterQuery.status !== "all") params.set("status", chapterQuery.status);
2470
+ if (chapterQuery.scene !== "") params.set("scene", chapterQuery.scene);
2471
+ params.set("sort", chapterQuery.sort);
2472
+ if (chapterQuery.cursor !== "") params.set("cursor", chapterQuery.cursor);
2473
+ } else if (tab === "characters" || tab === "foreshadows" || tab === "worldbook") {
2474
+ if (sectionSearch.trim() !== "") params.set("search", sectionSearch.trim());
2475
+ }
2476
+ const path = `${base}${base.includes("?") ? "&" : "?"}${params.toString()}`;
2477
+ const queryKey = `${tab}|${root ?? ""}|${JSON.stringify(chapterQuery)}|${sectionSearch}`;
2478
+ const cachedEtag = sectionEtagRef.current?.key === queryKey ? sectionEtagRef.current.etag : void 0;
2479
+ const controller = new AbortController();
2480
+ setSection((previous) => previous === null ? {
2481
+ loading: true,
2482
+ error: null,
2483
+ payload: null
2484
+ } : {
2485
+ ...previous,
2486
+ loading: true,
2487
+ error: null
2488
+ });
2489
+ fetchPanel(fetch, path, controller.signal, cachedEtag).then((result) => {
2490
+ if (controller.signal.aborted) return;
2491
+ if (result.notModified === true) {
2492
+ setSection((previous) => previous === null ? {
2493
+ loading: false,
2494
+ error: null,
2495
+ payload: null
2496
+ } : {
2497
+ ...previous,
2498
+ loading: false,
2499
+ error: null
2500
+ });
2501
+ return;
2502
+ }
2503
+ if (result.payload?.etag !== void 0) sectionEtagRef.current = {
2504
+ key: queryKey,
2505
+ etag: result.payload.etag
2506
+ };
2507
+ setSection({
2508
+ loading: false,
2509
+ error: result.error,
2510
+ payload: result.payload
2511
+ });
2512
+ });
2513
+ return () => controller.abort();
2514
+ }, [
2515
+ tab,
2516
+ view.state,
2517
+ selectedRoot,
2518
+ chapterQuery,
2519
+ revisionTick,
2520
+ sectionSearch
2521
+ ]);
1768
2522
  const selectProject = (0, react.useCallback)((root) => {
1769
2523
  queryRootRef.current = root;
1770
2524
  storeRoot(root);
1771
2525
  setSelectedRoot(root);
2526
+ setChapterDetail(null);
2527
+ setQualityDetail(null);
2528
+ detailController.current?.abort();
1772
2529
  setSwitching(true);
2530
+ setChapterQuery((previous) => ({
2531
+ ...previous,
2532
+ cursor: ""
2533
+ }));
1773
2534
  setView((previous) => ({
1774
2535
  ...previous,
1775
2536
  state: "loading",
@@ -1794,7 +2555,9 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1794
2555
  const doc = globalThis.document;
1795
2556
  if (win === void 0 || doc === void 0) return;
1796
2557
  const refreshWhenVisible = () => {
1797
- if (doc.visibilityState !== "hidden") load(true);
2558
+ if (doc.visibilityState === "hidden") return;
2559
+ if (Date.now() < nextSyncAt.current) return;
2560
+ load(true);
1798
2561
  };
1799
2562
  const timer = win.setInterval(refreshWhenVisible, 15e3);
1800
2563
  win.addEventListener("focus", refreshWhenVisible);
@@ -1802,15 +2565,55 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1802
2565
  return () => {
1803
2566
  alive.current = false;
1804
2567
  controllerRef.current?.abort();
2568
+ detailController.current?.abort();
1805
2569
  win.clearInterval(timer);
1806
2570
  win.removeEventListener("focus", refreshWhenVisible);
1807
2571
  doc.removeEventListener("visibilitychange", refreshWhenVisible);
1808
2572
  };
1809
2573
  }, [load]);
2574
+ const loadDetail = (section, chapter, setter) => {
2575
+ detailController.current?.abort();
2576
+ if (chapter === null) {
2577
+ setter(null);
2578
+ return;
2579
+ }
2580
+ const root = queryRootRef.current;
2581
+ const controller = new AbortController();
2582
+ detailController.current = controller;
2583
+ const path = `${panelPath(root)}&section=${section}&chapter=${encodeURIComponent(String(chapter))}`;
2584
+ setter({
2585
+ chapter,
2586
+ section: {
2587
+ loading: true,
2588
+ error: null,
2589
+ payload: null
2590
+ }
2591
+ });
2592
+ fetchPanel(fetch, path, controller.signal).then((result) => {
2593
+ if (!alive.current || controller.signal.aborted || queryRootRef.current !== root) return;
2594
+ setter({
2595
+ chapter,
2596
+ section: {
2597
+ loading: false,
2598
+ error: result.error,
2599
+ payload: result.payload
2600
+ }
2601
+ });
2602
+ });
2603
+ };
2604
+ const openChapterDetail = (chapter) => loadDetail("chapter_detail", chapter, setChapterDetail);
2605
+ const openQualityDetail = (chapter) => loadDetail("quality_detail", chapter, setQualityDetail);
1810
2606
  return panelElement({
1811
2607
  ...view,
1812
- selectedRoot
1813
- }, loading || switching, () => void load(false, queryRootRef.current), selectProject);
2608
+ selectedRoot,
2609
+ ...sync
2610
+ }, loading || switching, () => {
2611
+ nextSyncAt.current = 0;
2612
+ load(false, queryRootRef.current);
2613
+ }, selectProject, tab, setTab, section, chapterQuery, (patch) => setChapterQuery((previous) => ({
2614
+ ...previous,
2615
+ ...patch
2616
+ })), sectionSearch, setSectionSearch, chapterDetail, openChapterDetail, qualityDetail, openQualityDetail);
1814
2617
  }
1815
2618
  /** Sidebar icon: a small book mark, inheriting the current text colour. */
1816
2619
  function NovelIcon({ size }) {