@musnows/scriverse 0.4.6 → 0.4.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.
@@ -0,0 +1,57 @@
1
+ export type CropRect = {
2
+ x: number;
3
+ y: number;
4
+ size: number;
5
+ };
6
+
7
+ export type DisplayRect = {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ scale: number;
13
+ };
14
+
15
+ export type CropHandle = "nw" | "ne" | "sw" | "se";
16
+
17
+ export const AVATAR_CROP_OUTPUT_SIZE: number;
18
+ export const AVATAR_CROP_MIN_SIZE: number;
19
+
20
+ export function defaultCropRect(imageWidth: number, imageHeight: number): CropRect;
21
+ export function clampCropRect(
22
+ rect: Partial<CropRect> | null | undefined,
23
+ imageWidth: number,
24
+ imageHeight: number,
25
+ minSize?: number
26
+ ): CropRect;
27
+ export function moveCropRect(
28
+ rect: Partial<CropRect> | null | undefined,
29
+ deltaX: number,
30
+ deltaY: number,
31
+ imageWidth: number,
32
+ imageHeight: number
33
+ ): CropRect;
34
+ export function resizeCropRect(
35
+ rect: Partial<CropRect> | null | undefined,
36
+ handle: CropHandle | string,
37
+ pointerX: number,
38
+ pointerY: number,
39
+ imageWidth: number,
40
+ imageHeight: number
41
+ ): CropRect;
42
+ export function containImageRect(
43
+ imageWidth: number,
44
+ imageHeight: number,
45
+ viewportWidth: number,
46
+ viewportHeight: number
47
+ ): DisplayRect;
48
+ export function mapDisplayPointToImage(
49
+ point: { x: number; y: number },
50
+ displayRect: DisplayRect
51
+ ): { x: number; y: number };
52
+ export function mapImageRectToDisplay(cropRect: CropRect, displayRect: DisplayRect): {
53
+ x: number;
54
+ y: number;
55
+ size: number;
56
+ };
57
+ export function cropOutputSize(cropSize: number, maxSize?: number): number;
@@ -0,0 +1,201 @@
1
+ /** 头像裁剪输出边长上限(像素) */
2
+ export const AVATAR_CROP_OUTPUT_SIZE = 512;
3
+
4
+ /** 选区最小边长(原图像素) */
5
+ export const AVATAR_CROP_MIN_SIZE = 32;
6
+
7
+ /**
8
+ * @typedef {{ x: number, y: number, size: number }} CropRect
9
+ * @typedef {{ x: number, y: number, width: number, height: number, scale: number }} DisplayRect
10
+ */
11
+
12
+ function finitePositive(value) {
13
+ return Number.isFinite(value) && value > 0 ? value : 0;
14
+ }
15
+
16
+ /**
17
+ * 在图片内取居中最大正方形作为默认选区。
18
+ * @param {number} imageWidth
19
+ * @param {number} imageHeight
20
+ * @returns {CropRect}
21
+ */
22
+ export function defaultCropRect(imageWidth, imageHeight) {
23
+ const width = Math.max(0, Math.floor(finitePositive(imageWidth)));
24
+ const height = Math.max(0, Math.floor(finitePositive(imageHeight)));
25
+ const size = Math.min(width, height);
26
+ if (size < 1) return { x: 0, y: 0, size: 0 };
27
+ return {
28
+ x: Math.floor((width - size) / 2),
29
+ y: Math.floor((height - size) / 2),
30
+ size
31
+ };
32
+ }
33
+
34
+ /**
35
+ * 将正方形选区钳制在图片范围内。
36
+ * @param {Partial<CropRect>} rect
37
+ * @param {number} imageWidth
38
+ * @param {number} imageHeight
39
+ * @param {number} [minSize]
40
+ * @returns {CropRect}
41
+ */
42
+ export function clampCropRect(rect, imageWidth, imageHeight, minSize = AVATAR_CROP_MIN_SIZE) {
43
+ const width = Math.max(0, Math.floor(finitePositive(imageWidth)));
44
+ const height = Math.max(0, Math.floor(finitePositive(imageHeight)));
45
+ if (width < 1 || height < 1) return { x: 0, y: 0, size: 0 };
46
+
47
+ const maxSize = Math.min(width, height);
48
+ const floorMin = Math.max(1, Math.min(Math.floor(finitePositive(minSize)) || 1, maxSize));
49
+ let size = Math.floor(finitePositive(rect?.size));
50
+ if (size < floorMin) size = floorMin;
51
+ if (size > maxSize) size = maxSize;
52
+
53
+ let x = Math.floor(Number(rect?.x) || 0);
54
+ let y = Math.floor(Number(rect?.y) || 0);
55
+ if (x < 0) x = 0;
56
+ if (y < 0) y = 0;
57
+ if (x > width - size) x = width - size;
58
+ if (y > height - size) y = height - size;
59
+ return { x, y, size };
60
+ }
61
+
62
+ /**
63
+ * 平移选区。
64
+ * @param {CropRect} rect
65
+ * @param {number} deltaX
66
+ * @param {number} deltaY
67
+ * @param {number} imageWidth
68
+ * @param {number} imageHeight
69
+ * @returns {CropRect}
70
+ */
71
+ export function moveCropRect(rect, deltaX, deltaY, imageWidth, imageHeight) {
72
+ return clampCropRect({
73
+ x: (Number(rect?.x) || 0) + (Number(deltaX) || 0),
74
+ y: (Number(rect?.y) || 0) + (Number(deltaY) || 0),
75
+ size: rect?.size
76
+ }, imageWidth, imageHeight);
77
+ }
78
+
79
+ /**
80
+ * 从四角手柄调整正方形选区,对边角保持固定。
81
+ * @param {CropRect} rect
82
+ * @param {"nw"|"ne"|"sw"|"se"} handle
83
+ * @param {number} pointerX 原图像素坐标
84
+ * @param {number} pointerY 原图像素坐标
85
+ * @param {number} imageWidth
86
+ * @param {number} imageHeight
87
+ * @returns {CropRect}
88
+ */
89
+ export function resizeCropRect(rect, handle, pointerX, pointerY, imageWidth, imageHeight) {
90
+ const current = clampCropRect(rect, imageWidth, imageHeight);
91
+ const left = current.x;
92
+ const top = current.y;
93
+ const right = current.x + current.size;
94
+ const bottom = current.y + current.size;
95
+ const px = Number(pointerX) || 0;
96
+ const py = Number(pointerY) || 0;
97
+
98
+ let nextLeft = left;
99
+ let nextTop = top;
100
+ let nextRight = right;
101
+ let nextBottom = bottom;
102
+
103
+ if (handle === "nw") {
104
+ nextLeft = px;
105
+ nextTop = py;
106
+ } else if (handle === "ne") {
107
+ nextRight = px;
108
+ nextTop = py;
109
+ } else if (handle === "sw") {
110
+ nextLeft = px;
111
+ nextBottom = py;
112
+ } else {
113
+ nextRight = px;
114
+ nextBottom = py;
115
+ }
116
+
117
+ const widthSpan = Math.abs(nextRight - nextLeft);
118
+ const heightSpan = Math.abs(nextBottom - nextTop);
119
+ const size = Math.max(widthSpan, heightSpan);
120
+
121
+ if (handle === "nw") {
122
+ return clampCropRect({ x: right - size, y: bottom - size, size }, imageWidth, imageHeight);
123
+ }
124
+ if (handle === "ne") {
125
+ return clampCropRect({ x: left, y: bottom - size, size }, imageWidth, imageHeight);
126
+ }
127
+ if (handle === "sw") {
128
+ return clampCropRect({ x: right - size, y: top, size }, imageWidth, imageHeight);
129
+ }
130
+ return clampCropRect({ x: left, y: top, size }, imageWidth, imageHeight);
131
+ }
132
+
133
+ /**
134
+ * 计算 object-fit: contain 时图片在视口中的显示矩形。
135
+ * @param {number} imageWidth
136
+ * @param {number} imageHeight
137
+ * @param {number} viewportWidth
138
+ * @param {number} viewportHeight
139
+ * @returns {DisplayRect}
140
+ */
141
+ export function containImageRect(imageWidth, imageHeight, viewportWidth, viewportHeight) {
142
+ const width = finitePositive(imageWidth);
143
+ const height = finitePositive(imageHeight);
144
+ const viewW = finitePositive(viewportWidth);
145
+ const viewH = finitePositive(viewportHeight);
146
+ if (!width || !height || !viewW || !viewH) {
147
+ return { x: 0, y: 0, width: 0, height: 0, scale: 0 };
148
+ }
149
+ const scale = Math.min(viewW / width, viewH / height);
150
+ const displayWidth = width * scale;
151
+ const displayHeight = height * scale;
152
+ return {
153
+ x: (viewW - displayWidth) / 2,
154
+ y: (viewH - displayHeight) / 2,
155
+ width: displayWidth,
156
+ height: displayHeight,
157
+ scale
158
+ };
159
+ }
160
+
161
+ /**
162
+ * 视口坐标映射到原图像素坐标。
163
+ * @param {{ x: number, y: number }} point
164
+ * @param {DisplayRect} displayRect
165
+ * @returns {{ x: number, y: number }}
166
+ */
167
+ export function mapDisplayPointToImage(point, displayRect) {
168
+ const scale = finitePositive(displayRect?.scale);
169
+ if (!scale) return { x: 0, y: 0 };
170
+ return {
171
+ x: ((Number(point?.x) || 0) - (Number(displayRect.x) || 0)) / scale,
172
+ y: ((Number(point?.y) || 0) - (Number(displayRect.y) || 0)) / scale
173
+ };
174
+ }
175
+
176
+ /**
177
+ * 原图选区映射到视口显示矩形。
178
+ * @param {CropRect} cropRect
179
+ * @param {DisplayRect} displayRect
180
+ * @returns {{ x: number, y: number, size: number }}
181
+ */
182
+ export function mapImageRectToDisplay(cropRect, displayRect) {
183
+ const scale = finitePositive(displayRect?.scale);
184
+ return {
185
+ x: (Number(displayRect?.x) || 0) + (Number(cropRect?.x) || 0) * scale,
186
+ y: (Number(displayRect?.y) || 0) + (Number(cropRect?.y) || 0) * scale,
187
+ size: (Number(cropRect?.size) || 0) * scale
188
+ };
189
+ }
190
+
191
+ /**
192
+ * 裁剪输出边长:不超过原选区,也不超过上限。
193
+ * @param {number} cropSize
194
+ * @param {number} [maxSize]
195
+ * @returns {number}
196
+ */
197
+ export function cropOutputSize(cropSize, maxSize = AVATAR_CROP_OUTPUT_SIZE) {
198
+ const size = Math.max(1, Math.floor(finitePositive(cropSize)) || 1);
199
+ const limit = Math.max(1, Math.floor(finitePositive(maxSize)) || AVATAR_CROP_OUTPUT_SIZE);
200
+ return Math.min(size, limit);
201
+ }
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260725-develop-galaxy-ripple">
13
+ <link rel="stylesheet" href="/styles.css?v=20260725-module-header-scale">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -25,7 +25,7 @@
25
25
  </div>
26
26
  <form id="login-form" class="auth-form">
27
27
  <label>用户名<input name="username" autocomplete="username" maxlength="100" required></label>
28
- <label>密码<span class="password-field"><input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="200" required aria-describedby="login-lock-hint"><button class="password-toggle" type="button" data-password-toggle="login-password" aria-label="显示密码" aria-pressed="false" title="显示密码"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 12s3.3-5.5 9.5-5.5S21.5 12 21.5 12 18.2 17.5 12 17.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/></svg></button></span></label>
28
+ <label>密码<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="200" required aria-describedby="login-lock-hint"></label>
29
29
  <p id="login-lock-hint" class="auth-security-hint">5 分钟内连续输错 5 次密码,登录将锁定 30 分钟。</p>
30
30
  <div class="auth-captcha">
31
31
  <label>验证码<input name="captchaAnswer" autocomplete="off" inputmode="text" maxlength="8" spellcheck="false" required aria-describedby="login-captcha-hint"></label>
@@ -123,10 +123,10 @@
123
123
  <button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
124
124
  <button type="button" data-module="timeline"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>时间轴</button>
125
125
  <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
126
- <button type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
126
+ <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
127
127
  <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
128
128
  <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
129
- <button class="module-nav-secondary hidden" type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
129
+ <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
130
130
  <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
131
131
  <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
132
132
  <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
@@ -202,6 +202,7 @@
202
202
  <span id="chapter-stats" class="chapter-stats">0 字 · v0</span>
203
203
  <button id="insight-button" class="ghost-button" type="button">章节概览</button>
204
204
  <button id="versions-button" class="ghost-button" type="button">版本</button>
205
+ <button id="chapter-edit-button" class="primary-button hidden" type="button">编辑</button>
205
206
  <button id="tidy-blank-lines-button" class="ghost-button" type="button">整理空行</button>
206
207
  <button id="save-button" class="primary-button" type="button">保存正文</button>
207
208
  </div>
@@ -590,7 +591,7 @@
590
591
  <header class="account-settings-section-header"><h3 id="profile-settings-title">个人资料</h3><p>用于作品协作和历史记录中的作者标识。</p></header>
591
592
  <div class="avatar-settings">
592
593
  <div id="profile-avatar-preview" class="user-avatar profile-avatar-preview" role="img" aria-label="当前头像"><span class="user-avatar-fallback">作</span></div>
593
- <div class="avatar-settings-copy"><strong>头像图片</strong><small>支持 PNG、JPEG、WebP,文件不超过 5 MB,尺寸不超过 4096 × 4096 像素。</small></div>
594
+ <div class="avatar-settings-copy"><strong>头像图片</strong><small>支持 PNG、JPEG、WebP,文件不超过 5 MB。选择后可框选正方形选区再裁剪上传。</small></div>
594
595
  <div class="avatar-settings-actions"><button id="avatar-upload-button" class="ghost-button" type="button">上传头像</button><button id="avatar-remove-button" class="ghost-button hidden" type="button">移除头像</button></div>
595
596
  </div>
596
597
  <form id="profile-form" class="account-settings-form"><label>显示名称<input id="profile-display-name" name="displayName" maxlength="80" required></label><button class="primary-button" type="submit">保存名称</button></form>
@@ -613,6 +614,37 @@
613
614
  </div>
614
615
  </dialog>
615
616
 
617
+ <dialog id="avatar-crop-dialog" class="dialog avatar-crop-dialog" aria-labelledby="avatar-crop-dialog-title" aria-describedby="avatar-crop-dialog-description">
618
+ <div class="dialog-header">
619
+ <div>
620
+ <span class="eyebrow">个人账户</span>
621
+ <h2 id="avatar-crop-dialog-title">裁剪头像</h2>
622
+ <p id="avatar-crop-dialog-description" class="dialog-header-meta">拖动选区或四角手柄调整范围,圆形预览即最终显示效果。</p>
623
+ </div>
624
+ <button id="avatar-crop-close" class="dialog-close" aria-label="关闭裁剪" type="button">×</button>
625
+ </div>
626
+ <div class="avatar-crop-body">
627
+ <div id="avatar-crop-stage" class="avatar-crop-stage" tabindex="0" role="img" aria-label="头像裁剪选区">
628
+ <img id="avatar-crop-image" class="avatar-crop-image" alt="">
629
+ <div id="avatar-crop-shade" class="avatar-crop-shade" aria-hidden="true"></div>
630
+ <div id="avatar-crop-selection" class="avatar-crop-selection" hidden>
631
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="nw" aria-label="调整左上角"></button>
632
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="ne" aria-label="调整右上角"></button>
633
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="sw" aria-label="调整左下角"></button>
634
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="se" aria-label="调整右下角"></button>
635
+ </div>
636
+ </div>
637
+ <aside class="avatar-crop-side">
638
+ <div id="avatar-crop-preview" class="user-avatar avatar-crop-preview" role="img" aria-label="裁剪预览"></div>
639
+ <p>预览</p>
640
+ </aside>
641
+ </div>
642
+ <div class="dialog-actions">
643
+ <button id="avatar-crop-cancel" class="ghost-button" type="button">取消</button>
644
+ <button id="avatar-crop-confirm" class="primary-button" type="button">确认裁剪</button>
645
+ </div>
646
+ </dialog>
647
+
616
648
  <dialog id="members-dialog" class="dialog wide-dialog" aria-labelledby="members-dialog-title">
617
649
  <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">成员模块权限</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
618
650
  <div class="access-dialog-body">
@@ -699,6 +731,6 @@
699
731
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
700
732
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
701
733
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
702
- <script type="module" src="/app.js?v=20260725-develop-galaxy-ripple"></script>
734
+ <script type="module" src="/app.js?v=20260725-module-header-scale"></script>
703
735
  </body>
704
736
  </html>
@@ -503,10 +503,11 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
503
503
  .app-shell.left-panel-collapsed .panel-heading { display: none; }
504
504
  .app-shell.left-panel-collapsed .brand-block { padding: 0 4px; }.app-shell.left-panel-collapsed .brand-block > div { display: none; }
505
505
  .main-panel { min-width: 0; min-height: 0; overflow: hidden; }
506
- .app-shell.shelf-mode { grid-template-columns: 1fr; }
506
+ .app-shell.shelf-mode { grid-template-columns: minmax(0, 1fr); }
507
507
  .app-shell.shelf-mode .topbar { grid-template-columns: 280px 1fr auto; }
508
508
  .app-shell.shelf-mode .left-panel, .app-shell.shelf-mode .ai-panel { display: none; }
509
509
  .app-shell.shelf-mode .save-state { display: none; }
510
+ .app-shell.shelf-mode #top-search-button { display: none; }
510
511
  .app-shell.shelf-mode .main-panel { grid-column: 1 / -1; }
511
512
 
512
513
  .shelf-view { display: flex; flex-direction: column; height: 100%; overflow-y: auto; padding: 48px clamp(32px, 7vw, 110px) 90px; }
@@ -618,17 +619,23 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
618
619
  .app-shell.prose-hidden-mode:not(.shelf-mode) .panel-heading,
619
620
  .app-shell.prose-hidden-mode:not(.shelf-mode) #novel-tree { display: none !important; }
620
621
  .app-shell.prose-read-only-mode:not(.shelf-mode) #tidy-blank-lines-button,
621
- .app-shell.prose-read-only-mode:not(.shelf-mode) #save-button { display: none !important; }
622
+ .app-shell.prose-read-only-mode:not(.shelf-mode) #save-button,
623
+ .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-edit-button { display: none !important; }
622
624
  .app-shell.prose-read-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
623
625
  .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-title[readonly],
624
626
  .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-content[readonly] { cursor: default; }
625
627
  .app-shell.view-only-mode:not(.shelf-mode) #module-create-button,
626
628
  .app-shell.view-only-mode:not(.shelf-mode) [data-work-settings],
627
629
  .app-shell.view-only-mode:not(.shelf-mode) #tidy-blank-lines-button,
628
- .app-shell.view-only-mode:not(.shelf-mode) #save-button { display: none !important; }
630
+ .app-shell.view-only-mode:not(.shelf-mode) #save-button,
631
+ .app-shell.view-only-mode:not(.shelf-mode) #chapter-edit-button { display: none !important; }
629
632
  .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
630
633
  .app-shell.view-only-mode:not(.shelf-mode) #chapter-title[readonly],
631
634
  .app-shell.view-only-mode:not(.shelf-mode) #chapter-content[readonly] { cursor: default; }
635
+ .editor-view.is-read-only #tidy-blank-lines-button,
636
+ .editor-view.is-read-only #save-button { display: none !important; }
637
+ .editor-view.is-read-only #chapter-title[readonly],
638
+ .editor-view.is-read-only #chapter-content[readonly] { cursor: default; }
632
639
  body.work-viewer-mode [data-setting-status],
633
640
  body.work-viewer-mode [data-edit-setting],
634
641
  body.work-viewer-mode [data-edit-character],
@@ -746,6 +753,74 @@ body.work-viewer-mode .character-editor-actions { display: none !important; }
746
753
  .avatar-settings-actions { display: flex; flex-wrap: wrap; gap: 8px; }
747
754
  .avatar-settings-actions button { min-height: 32px; padding: 6px 11px; font-size: 11px; }
748
755
  .avatar-settings-actions button:disabled { cursor: wait; opacity: .5; }
756
+ .avatar-crop-dialog { width: min(720px, 94vw); }
757
+ .avatar-crop-body { display: grid; grid-template-columns: minmax(0, 1fr) 96px; gap: 16px; align-items: start; padding: 18px 24px 8px; }
758
+ .avatar-crop-stage {
759
+ position: relative;
760
+ display: grid;
761
+ place-items: center;
762
+ width: 100%;
763
+ aspect-ratio: 1;
764
+ max-height: min(52vh, 480px);
765
+ overflow: hidden;
766
+ border: 1px solid var(--line);
767
+ border-radius: 6px;
768
+ background:
769
+ linear-gradient(45deg, color-mix(in srgb, var(--line) 55%, transparent) 25%, transparent 25%) 0 0 / 16px 16px,
770
+ linear-gradient(-45deg, color-mix(in srgb, var(--line) 55%, transparent) 25%, transparent 25%) 0 8px / 16px 16px,
771
+ var(--surface-soft);
772
+ touch-action: none;
773
+ user-select: none;
774
+ }
775
+ .avatar-crop-image {
776
+ position: absolute;
777
+ inset: 0;
778
+ width: 100%;
779
+ height: 100%;
780
+ object-fit: contain;
781
+ pointer-events: none;
782
+ }
783
+ .avatar-crop-shade {
784
+ position: absolute;
785
+ inset: 0;
786
+ pointer-events: none;
787
+ background: color-mix(in srgb, var(--ink) 42%, transparent);
788
+ -webkit-mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
789
+ mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
790
+ -webkit-mask-repeat: no-repeat;
791
+ mask-repeat: no-repeat;
792
+ -webkit-mask-composite: xor;
793
+ mask-composite: exclude;
794
+ }
795
+ .avatar-crop-selection {
796
+ position: absolute;
797
+ box-sizing: border-box;
798
+ border: 2px solid color-mix(in srgb, white 88%, var(--accent));
799
+ border-radius: 2px;
800
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink) 35%, transparent);
801
+ cursor: grab;
802
+ touch-action: none;
803
+ }
804
+ .avatar-crop-selection.is-dragging { cursor: grabbing; }
805
+ .avatar-crop-selection[hidden] { display: none; }
806
+ .avatar-crop-handle {
807
+ position: absolute;
808
+ width: 12px;
809
+ height: 12px;
810
+ padding: 0;
811
+ border: 2px solid color-mix(in srgb, white 90%, var(--accent));
812
+ border-radius: 2px;
813
+ background: var(--accent);
814
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink) 28%, transparent);
815
+ }
816
+ .avatar-crop-handle[data-avatar-crop-handle="nw"] { top: -7px; left: -7px; cursor: nwse-resize; }
817
+ .avatar-crop-handle[data-avatar-crop-handle="ne"] { top: -7px; right: -7px; cursor: nesw-resize; }
818
+ .avatar-crop-handle[data-avatar-crop-handle="sw"] { bottom: -7px; left: -7px; cursor: nesw-resize; }
819
+ .avatar-crop-handle[data-avatar-crop-handle="se"] { bottom: -7px; right: -7px; cursor: nwse-resize; }
820
+ .avatar-crop-side { display: grid; justify-items: center; gap: 8px; padding-top: 8px; }
821
+ .avatar-crop-side p { margin: 0; color: var(--muted); font-size: 10px; }
822
+ .avatar-crop-preview { width: 76px; height: 76px; border: 2px solid color-mix(in srgb, var(--line) 72%, transparent); background: var(--surface); }
823
+ .avatar-crop-preview canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; }
749
824
  .account-settings-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px; margin-top: 16px; }
750
825
  .account-settings-form label { display: grid; min-width: 0; gap: 6px; color: var(--muted); font-size: 11px; }
751
826
  .account-settings-form input { width: 100%; min-height: 38px; padding: 9px 10px; background: var(--surface); font-size: 12px; }
@@ -877,7 +952,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
877
952
  .chapter-add-button { flex: none; }
878
953
  .volume-chapters { display: block; }
879
954
  .volume-node.is-collapsed .volume-chapters { display: none; }
880
- .chapter-node { display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; width: calc(100% + 6px); padding: 9px 8px 9px 20px; border: 0; background: transparent; border-radius: 4px; text-align: left; font-size: 12px; }
955
+ .chapter-node { display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; width: calc(100% + 6px); padding: 9px 8px 9px 20px; border: 0; background: transparent; border-radius: 4px; text-align: left; font-size: 12px; }
881
956
  .chapter-node:hover, .chapter-node.active { background: var(--row-active); }
882
957
  .chapter-node.active { color: var(--accent-dark); font-weight: 700; }
883
958
  .chapter-node > span:first-child { min-width: 0; overflow-wrap: anywhere; line-height: 1.45; white-space: normal; }
@@ -944,7 +1019,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
944
1019
  .module-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
945
1020
  .module-header-actions > [data-module-header-action] { order: 1; }
946
1021
  .module-header-actions > #module-create-button { order: 2; min-height: 36px; }
947
- .module-header h1 { margin: 0 0 8px; font-weight: 500; font-size: 34px; }
1022
+ .module-count-badge { display: inline-grid; min-width: 28px; height: 28px; padding: 0 7px; place-items: center; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--accent-dark); font-family: var(--font-latin), monospace; font-size: 11px; font-weight: 650; }
1023
+ .module-header h1 { margin: 0 0 6px; font-weight: 500; font-size: 24px; line-height: 1.25; }
948
1024
  .module-header p { margin: 0; color: var(--muted); font-size: 13px; }
949
1025
  .module-content { padding: 24px 0 80px; }
950
1026
  .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 13px; }
@@ -962,6 +1038,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
962
1038
  .module-filter-toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
963
1039
  .module-filter-toggle + .module-layout-toolbar { order: 2; }
964
1040
  .module-filter-toggle ~ #module-create-button { order: 3; }
1041
+ .race-tree-expand-toolbar ~ .module-layout-toolbar { order: 2; }
1042
+ .race-tree-expand-toolbar ~ #module-create-button { order: 3; }
965
1043
  .settings-layout-hint { color: var(--muted); font-size: 10px; }
966
1044
  .settings-layout-toggle, .module-layout-toggle { display: inline-flex; border: 1px solid var(--line); border-radius: 4px; overflow: hidden; background: var(--surface); }
967
1045
  .settings-layout-toggle button { min-height: 34px; padding: 0 12px; border: 0; border-right: 1px solid var(--line); background: transparent; color: var(--muted); font-size: 11px; }
@@ -982,6 +1060,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
982
1060
  .setting-row .card-actions, .module-row .card-actions { margin-top: 0; flex-wrap: wrap; justify-content: flex-end; }
983
1061
  .module-row .card-actions > .record-card-edit { position: static; }
984
1062
  .character-card { cursor: pointer; }
1063
+ .character-row { grid-template-columns: minmax(140px, .28fr) minmax(0, 1fr) auto; }
1064
+ .character-card-heading { display: flex; min-width: 0; align-items: center; gap: 7px; }
1065
+ .character-card-heading h3 { min-width: 0; }
1066
+ .record-card.has-card-edit .character-card-heading { padding-right: 34px; }
1067
+ .record-card.has-card-edit .character-card-heading h3 { padding-right: 0; }
1068
+ .character-lock-badge { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 3px; min-height: 20px; padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent-dark); font-size: 9px; font-variant-numeric: tabular-nums; line-height: 1; }
1069
+ .character-lock-badge svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
985
1070
  .character-filter-toolbar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; gap: 12px; align-items: start; width: 100%; min-width: 0; box-sizing: border-box; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--line); background: var(--surface-soft); }
986
1071
  .character-filter-dropdown { position: relative; min-width: 0; }
987
1072
  .character-filter-dropdown summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 38px; padding: 0 11px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); cursor: pointer; list-style: none; }
@@ -1399,6 +1484,12 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1399
1484
  .book-summary-context-percent-field { width: min(100%, 280px); }
1400
1485
  .context-compact-threshold-field { width: min(100%, 280px); }
1401
1486
  .book-summary-context-percent-field input, .context-compact-threshold-field input { width: 64px; min-height: 32px; padding: 5px 8px; font-size: 13px; font-family: var(--font-latin), monospace; }
1487
+ .config-inline-save { display: flex; align-items: flex-end; gap: 10px; margin: 14px 0 4px; flex-wrap: wrap; }
1488
+ .config-inline-save .book-summary-context-percent-field,
1489
+ .config-inline-save .context-compact-threshold-field { display: grid; gap: 6px; width: auto; margin: 0; color: var(--muted); font-size: 10px; }
1490
+ .config-section .config-save-button { min-height: 32px; padding: 5px 11px; font-size: 11px; }
1491
+ .config-section .card-actions { margin-top: 12px; }
1492
+ .config-section .card-actions .config-save-button { border: 1px solid var(--line); background: transparent; color: var(--ink); }
1402
1493
  .field-label textarea {
1403
1494
  width: 100%;
1404
1495
  min-height: 140px;
@@ -1633,6 +1724,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1633
1724
  .merge-dialog-note { margin: 0; padding: 10px 12px; border-left: 2px solid var(--accent); background: var(--surface-soft); color: var(--muted); font-size: 11px; line-height: 1.6; }
1634
1725
  .form-field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
1635
1726
  .analysis-type-description { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
1727
+ .task-chapter-field { transition: opacity .15s ease; }
1728
+ .task-chapter-field.is-disabled { opacity: .48; }
1729
+ .task-chapter-field select:disabled { cursor: not-allowed; }
1636
1730
  .model-temperature-hint { margin: 0; color: var(--accent-dark); font-size: 10px; line-height: 1.55; }
1637
1731
  .item-list-rows { display: grid; gap: 7px; }
1638
1732
  .item-list-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
@@ -1658,6 +1752,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1658
1752
  .character-card h3 { margin-bottom: 5px; }
1659
1753
  .character-identity { margin: 0 0 6px !important; color: var(--ink) !important; font-weight: 600; line-height: 1.55 !important; }
1660
1754
  .character-aliases { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; }
1755
+ .character-aliases b { margin-right: 2px; color: var(--muted); font-size: 9px; font-weight: 500; }
1661
1756
  .character-aliases .pill { margin: 0; border: 1px solid var(--line); background: var(--surface); }
1662
1757
  .character-code { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
1663
1758
  .character-species { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 10px; }
@@ -2013,14 +2108,22 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2013
2108
  }
2014
2109
 
2015
2110
  @media (max-width: 640px) {
2016
- .setting-editor-heading { gap: 6px; }
2017
- .setting-editor-header-fields { align-items: flex-start; flex-direction: column; }
2111
+ #onboarding-dialog, #onboarding-menu-button { display: none !important; }
2112
+ .topbar .work-meta { display: none; }
2113
+ .top-actions .save-state { display: none; }
2114
+ .presence-button #presence-count { display: none; }
2115
+ .account-button > span:last-child { display: none; }
2116
+ .book-shelf { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 14px; }
2117
+ .book-info > span, .book-access-badge { display: none; }
2018
2118
  .ai-history-dialog-body { min-height: min(360px, 62vh); padding: 12px 14px 18px; }
2019
2119
  .account-settings-body { padding: 16px; }
2020
2120
  .account-settings-section { padding: 15px; }
2021
2121
  .account-settings-form, .password-settings-form, .api-key-settings, .api-key-result { grid-template-columns: minmax(0, 1fr); }
2022
2122
  .avatar-settings { grid-template-columns: 64px minmax(0, 1fr); }
2023
2123
  .profile-avatar-preview { width: 64px; height: 64px; }
2124
+ .avatar-crop-body { grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 14px 16px 4px; }
2125
+ .avatar-crop-side { grid-template-columns: auto 1fr; align-items: center; justify-items: start; gap: 10px; padding-top: 0; }
2126
+ .avatar-crop-preview { width: 64px; height: 64px; }
2024
2127
  .account-settings-form .primary-button { width: 100%; }
2025
2128
  }
2026
2129
 
@@ -2060,6 +2163,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2060
2163
  .brand-block strong { font-size: 14px; }
2061
2164
  .brand-block small { display: none; }
2062
2165
  .mobile-module-tab { position: fixed; z-index: 32; top: 132px; right: 0; display: grid; width: 34px; height: 42px; padding: 0; place-items: center; border: 1px solid var(--line); border-right: 0; border-radius: 5px 0 0 5px; background: var(--surface-soft); color: var(--accent-dark); font-size: 10px; writing-mode: vertical-rl; }
2166
+ .app-shell.shelf-mode .mobile-module-tab { display: none; }
2063
2167
  .mobile-panel-backdrop { position: fixed; z-index: 30; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; background: rgba(20, 17, 14, .28); }
2064
2168
  .app-shell:not(.shelf-mode):not(.left-panel-collapsed) .mobile-panel-backdrop { display: block; }
2065
2169
  .work-meta {
@@ -2183,7 +2287,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2183
2287
 
2184
2288
  .module-view { padding: 26px var(--mobile-gutter) 46px; }
2185
2289
  .module-header { padding-bottom: 18px; }
2186
- .module-header h1 { font-size: 29px; }
2290
+ .module-header h1 { font-size: 22px; }
2187
2291
  .module-header-actions { justify-content: stretch; }
2188
2292
  .module-header-actions > * { flex: 1 1 auto; }
2189
2293
  .module-content { padding: 18px 0 56px; }
@@ -2243,7 +2347,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2243
2347
  .editor-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
2244
2348
  .editor-actions .chapter-stats { display: none; }
2245
2349
  .editor-actions > button { width: auto; }
2246
- .book-shelf { grid-template-columns: minmax(0, 1fr); max-width: 280px; }
2350
+ .book-shelf { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; max-width: none; }
2247
2351
  .book-add-card { min-height: 220px; }
2248
2352
  .onboarding-popover { width: calc(100vw - 20px); }
2249
2353
  }
package/dist/store.js CHANGED
@@ -608,7 +608,9 @@ export class Store {
608
608
  autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
609
609
  bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
610
610
  contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
611
- agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","query_story_knowledge","grep","read_character_sections"]'), ["story_index", "read_chapters", "query_story_knowledge", "grep", "read_character_sections"]),
611
+ agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","search_story_entities","grep","read_character_sections"]'), ["story_index", "read_chapters", "search_story_entities", "grep", "read_character_sections"])
612
+ .map((tool) => tool === "query_story_knowledge" ? "search_story_entities" : tool)
613
+ .filter((tool, index, tools) => tools.indexOf(tool) === index),
612
614
  updatedAt: String(row?.updated_at ?? "")
613
615
  };
614
616
  }
@@ -2282,8 +2284,9 @@ export class Store {
2282
2284
  listCharactersPage(workId, pagination, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
2283
2285
  this.getWork(workId);
2284
2286
  const page = paginationSql(pagination);
2287
+ const count = this.db.get(`SELECT COUNT(*) AS count FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"}`, workId);
2285
2288
  const rows = this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY name${page.sql}`, workId, ...page.params);
2286
- return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown)), pagination);
2289
+ return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown)), pagination, Number(count?.count ?? 0));
2287
2290
  }
2288
2291
  mapCharacterProfileSection(row) {
2289
2292
  return {