@lmzhen/dsh-evolution-settings-ui 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -13,62 +13,122 @@ window.__ModuleLoader__.load({
13
13
  {
14
14
  id: "curatorIntervalHours",
15
15
  group: "curator",
16
- doc: "Minimum hours between deterministic curation passes."
16
+ doc: "Minimum hours between deterministic curation passes.",
17
+ label: "策展间隔",
18
+ hint: "两次确定性策展之间至少间隔多少小时。",
19
+ control: "number",
20
+ unit: "小时",
21
+ values: []
17
22
  },
18
23
  {
19
24
  id: "staleAfterDays",
20
25
  group: "curator",
21
- doc: "Inactive days before a skill counts as stale."
26
+ doc: "Inactive days before a skill counts as stale.",
27
+ label: "判为陈旧的天数",
28
+ hint: "技能多少天没被使用即算陈旧。",
29
+ control: "number",
30
+ unit: "天",
31
+ values: []
22
32
  },
23
33
  {
24
34
  id: "archiveAfterDays",
25
35
  group: "curator",
26
- doc: "Inactive days before a stale skill is archived (must be >= staleAfterDays)."
36
+ doc: "Inactive days before a stale skill is archived (must be >= staleAfterDays).",
37
+ label: "归档天数",
38
+ hint: "陈旧技能再过多少天归档(须 ≥ 判为陈旧的天数)。",
39
+ control: "number",
40
+ unit: "天",
41
+ values: []
27
42
  },
28
43
  {
29
44
  id: "qualityWarnStaleAfterDays",
30
45
  group: "curator",
31
- doc: "Age at which a low quality score starts warning."
46
+ doc: "Age at which a low quality score starts warning.",
47
+ label: "低质量告警天数",
48
+ hint: "质量分偏低时从多少天开始告警。",
49
+ control: "number",
50
+ unit: "天",
51
+ values: []
32
52
  },
33
53
  {
34
54
  id: "minIdleHours",
35
55
  group: "curator",
36
- doc: "Idle hours required before an automatic curation pass runs."
56
+ doc: "Idle hours required before an automatic curation pass runs.",
57
+ label: "空闲门槛",
58
+ hint: "至少空闲多少小时才跑自动策展。",
59
+ control: "number",
60
+ unit: "小时",
61
+ values: []
37
62
  },
38
63
  {
39
64
  id: "minIdleFailOpen",
40
65
  group: "curator",
41
- doc: "Let the idle gate open when the activity probe is unavailable."
66
+ doc: "Let the idle gate open when the activity probe is unavailable.",
67
+ label: "空闲探针失败时放行",
68
+ hint: "活动探针不可用时,是否让空闲门槛直接放行。",
69
+ control: "switch",
70
+ unit: "",
71
+ values: []
42
72
  },
43
73
  {
44
74
  id: "llmReview",
45
75
  group: "curator",
46
- doc: "Enable the LLM nomination pass on top of the deterministic lifecycle."
76
+ doc: "Enable the LLM nomination pass on top of the deterministic lifecycle.",
77
+ label: "启用 LLM 提名",
78
+ hint: "在确定性生命周期之上再跑一遍 LLM 提名。",
79
+ control: "switch",
80
+ unit: "",
81
+ values: []
47
82
  },
48
83
  {
49
84
  id: "curatorReviewMaxTokens",
50
85
  group: "curator",
51
- doc: "Token budget of the curator LLM review."
86
+ doc: "Token budget of the curator LLM review.",
87
+ label: "策展 LLM token 预算",
88
+ hint: "策展 LLM 审查可用的 token 预算。",
89
+ control: "number",
90
+ unit: "token",
91
+ values: []
52
92
  },
53
93
  {
54
94
  id: "curatorReviewTimeoutMs",
55
95
  group: "curator",
56
- doc: "Wall-clock bound of the curator LLM review."
96
+ doc: "Wall-clock bound of the curator LLM review.",
97
+ label: "策展 LLM 超时",
98
+ hint: "策展 LLM 审查的墙钟上限。",
99
+ control: "number",
100
+ unit: "毫秒",
101
+ values: []
57
102
  },
58
103
  {
59
104
  id: "healthSoftBodyChars",
60
105
  group: "curator",
61
- doc: "Body character line the health view judges against."
106
+ doc: "Body character line the health view judges against.",
107
+ label: "正文软带",
108
+ hint: "健康视图判定正文长度的软带。",
109
+ control: "number",
110
+ unit: "字符",
111
+ values: []
62
112
  },
63
113
  {
64
114
  id: "healthStampDensityPerKb",
65
115
  group: "curator",
66
- doc: "Stamp density per KB that flags log-like content in a body."
116
+ doc: "Stamp density per KB that flags log-like content in a body.",
117
+ label: "标记密度阈值",
118
+ hint: "每 KB 出现多少个时间标记就算「像日志」。",
119
+ control: "number",
120
+ unit: "处/KB",
121
+ values: []
67
122
  },
68
123
  {
69
124
  id: "healthChurnMinPatches",
70
125
  group: "curator",
71
- doc: "Patches without a read that flag a write-ghost skill."
126
+ doc: "Patches without a read that flag a write-ghost skill.",
127
+ label: "空写判定补丁数",
128
+ hint: "多少次改动都没有任何读取,即判为「写幽灵」。",
129
+ control: "number",
130
+ unit: "处",
131
+ values: []
72
132
  }
73
133
  ]
74
134
  },
@@ -78,22 +138,42 @@ window.__ModuleLoader__.load({
78
138
  {
79
139
  id: "memoryChars",
80
140
  group: "memory",
81
- doc: "Character budget the memory store enforces for MEMORY.md."
141
+ doc: "Character budget the memory store enforces for MEMORY.md.",
142
+ label: "MEMORY.md 字符预算",
143
+ hint: "memory 存储对 MEMORY.md 的字符上限。",
144
+ control: "number",
145
+ unit: "字符",
146
+ values: []
82
147
  },
83
148
  {
84
149
  id: "userChars",
85
150
  group: "memory",
86
- doc: "Character budget the memory store enforces for USER.md."
151
+ doc: "Character budget the memory store enforces for USER.md.",
152
+ label: "USER.md 字符预算",
153
+ hint: "memory 存储对 USER.md 的字符上限。",
154
+ control: "number",
155
+ unit: "字符",
156
+ values: []
87
157
  },
88
158
  {
89
159
  id: "addDatePrefix",
90
160
  group: "memory",
91
- doc: "Prefix stored memory entries with their date heading."
161
+ doc: "Prefix stored memory entries with their date heading.",
162
+ label: "记忆条目加日期前缀",
163
+ hint: "写入记忆时为条目加上日期标题。",
164
+ control: "switch",
165
+ unit: "",
166
+ values: []
92
167
  },
93
168
  {
94
169
  id: "maxConsolidationFailures",
95
170
  group: "memory",
96
- doc: "Consolidation failures one turn tolerates before the tool gives up."
171
+ doc: "Consolidation failures one turn tolerates before the tool gives up.",
172
+ label: "合并失败容忍次数",
173
+ hint: "一轮内合并失败多少次后工具放弃。",
174
+ control: "number",
175
+ unit: "次",
176
+ values: []
97
177
  }
98
178
  ]
99
179
  },
@@ -103,37 +183,76 @@ window.__ModuleLoader__.load({
103
183
  {
104
184
  id: "reviewSkillInterval",
105
185
  group: "review",
106
- doc: "Activity units between skill-review injections."
186
+ doc: "Activity units between skill-review injections.",
187
+ label: "技能审查间隔",
188
+ hint: "每多少活动单位触发一次技能审查注入。",
189
+ control: "number",
190
+ unit: "次",
191
+ values: []
107
192
  },
108
193
  {
109
194
  id: "reviewMemoryInterval",
110
195
  group: "review",
111
- doc: "Activity units between memory-review injections."
196
+ doc: "Activity units between memory-review injections.",
197
+ label: "记忆审查间隔",
198
+ hint: "每多少活动单位触发一次记忆审查注入。",
199
+ control: "number",
200
+ unit: "次",
201
+ values: []
112
202
  },
113
203
  {
114
204
  id: "skillReviewTrigger",
115
205
  group: "review",
116
- doc: "Which channel may inject a skill review (cadence, completion, both)."
206
+ doc: "Which channel may inject a skill review (cadence, completion, both).",
207
+ label: "审查触发通道",
208
+ hint: "哪个通道可以注入技能审查:cadence=按间隔、completion=按完成、both=两者。",
209
+ control: "select",
210
+ unit: "",
211
+ values: [
212
+ "cadence",
213
+ "completion",
214
+ "both"
215
+ ]
117
216
  },
118
217
  {
119
218
  id: "skillReviewCompletionMinToolCalls",
120
219
  group: "review",
121
- doc: "Tool calls a task needs before the completion channel injects."
220
+ doc: "Tool calls a task needs before the completion channel injects.",
221
+ label: "完成通道最少工具调用",
222
+ hint: "走完成通道时,任务至少经过多少次工具调用才注入。",
223
+ control: "number",
224
+ unit: "次",
225
+ values: []
122
226
  },
123
227
  {
124
228
  id: "reviewEnabled",
125
229
  group: "review",
126
- doc: "Master switch for the review plugin."
230
+ doc: "Master switch for the review plugin.",
231
+ label: "启用审查插件",
232
+ hint: "审查插件的总开关。",
233
+ control: "switch",
234
+ unit: "",
235
+ values: []
127
236
  },
128
237
  {
129
238
  id: "reviewMode",
130
239
  group: "review",
131
- doc: "Run the review in the parent session (inject) or on a subagent."
240
+ doc: "Run the review in the parent session (inject) or on a subagent.",
241
+ label: "审查运行方式",
242
+ hint: "inject=在父会话内注入,subagent=交给子代理运行。",
243
+ control: "select",
244
+ unit: "",
245
+ values: ["subagent", "inject"]
132
246
  },
133
247
  {
134
248
  id: "reviewWakeInject",
135
249
  group: "review",
136
- doc: "Deliver the deferred review as a waking follow-up message."
250
+ doc: "Deliver the deferred review as a waking follow-up message.",
251
+ label: "审查后续唤醒注入",
252
+ hint: "把延后的审查作为唤醒消息投递。",
253
+ control: "switch",
254
+ unit: "",
255
+ values: []
137
256
  }
138
257
  ]
139
258
  },
@@ -143,42 +262,82 @@ window.__ModuleLoader__.load({
143
262
  {
144
263
  id: "skillContentChars",
145
264
  group: "write-caps",
146
- doc: "Character cap on a SKILL.md body (tighten-only)."
265
+ doc: "Character cap on a SKILL.md body (tighten-only).",
266
+ label: "SKILL.md 正文字符上限",
267
+ hint: "SKILL.md 正文的字符上限(只许收紧)。",
268
+ control: "number",
269
+ unit: "字符",
270
+ values: []
147
271
  },
148
272
  {
149
273
  id: "maxSkillFileBytes",
150
274
  group: "write-caps",
151
- doc: "Byte cap on one support file (tighten-only)."
275
+ doc: "Byte cap on one support file (tighten-only).",
276
+ label: "支持文件字节上限",
277
+ hint: "单个支持文件的字节上限(只许收紧)。",
278
+ control: "number",
279
+ unit: "字节",
280
+ values: []
152
281
  },
153
282
  {
154
283
  id: "maxSkillNameLength",
155
284
  group: "write-caps",
156
- doc: "Character cap on a skill name (tighten-only)."
285
+ doc: "Character cap on a skill name (tighten-only).",
286
+ label: "技能名字符上限",
287
+ hint: "技能名的字符上限(只许收紧)。",
288
+ control: "number",
289
+ unit: "字符",
290
+ values: []
157
291
  },
158
292
  {
159
293
  id: "maxDescriptionLength",
160
294
  group: "write-caps",
161
- doc: "Character cap on a skill description (tighten-only)."
295
+ doc: "Character cap on a skill description (tighten-only).",
296
+ label: "技能描述字符上限",
297
+ hint: "技能描述的字符上限(只许收紧)。",
298
+ control: "number",
299
+ unit: "字符",
300
+ values: []
162
301
  },
163
302
  {
164
303
  id: "descriptionStrict",
165
304
  group: "write-caps",
166
- doc: "Refuse a description over the authoring bar instead of advising."
305
+ doc: "Refuse a description over the authoring bar instead of advising.",
306
+ label: "描述严格档",
307
+ hint: "描述超过写作门槛时拒绝写入,而不是只提示。",
308
+ control: "switch",
309
+ unit: "",
310
+ values: []
167
311
  },
168
312
  {
169
313
  id: "strictCrossSource",
170
314
  group: "write-caps",
171
- doc: "Refuse writes whose catalog entry resolves outside the family."
315
+ doc: "Refuse writes whose catalog entry resolves outside the family.",
316
+ label: "跨源严格档",
317
+ hint: "目录项解析到家族之外时拒绝写入。",
318
+ control: "switch",
319
+ unit: "",
320
+ values: []
172
321
  },
173
322
  {
174
323
  id: "citationPolicy",
175
324
  group: "write-caps",
176
- doc: "Refuse a move that would leave a dangling reference, or verify it."
325
+ doc: "Refuse a move that would leave a dangling reference, or verify it.",
326
+ label: "引用校验档",
327
+ hint: "移动或合并时对引用的处置:verify=校验、refuse=直接拒绝。",
328
+ control: "select",
329
+ unit: "",
330
+ values: ["verify", "refuse"]
177
331
  },
178
332
  {
179
333
  id: "supportFileCharPolicy",
180
334
  group: "write-caps",
181
- doc: "Warn about an oversize support file, or refuse the write."
335
+ doc: "Warn about an oversize support file, or refuse the write.",
336
+ label: "支持文件超限档",
337
+ hint: "支持文件超出字符上限时:report=只报告、enforce=拒写。",
338
+ control: "select",
339
+ unit: "",
340
+ values: ["report", "enforce"]
182
341
  }
183
342
  ]
184
343
  },
@@ -187,11 +346,118 @@ window.__ModuleLoader__.load({
187
346
  fields: [{
188
347
  id: "entryPreviewChars",
189
348
  group: "memory",
190
- doc: "Characters of one memory entry shown in a tool result preview."
349
+ doc: "Characters of one memory entry shown in a tool result preview.",
350
+ label: "条目预览字符数",
351
+ hint: "单条记忆预览保留多少字符。",
352
+ control: "number",
353
+ unit: "字符",
354
+ values: []
191
355
  }]
192
356
  }
193
357
  ];
194
358
  //#endregion
359
+ //#region src/client/messages.ts
360
+ /**
361
+ * Copy for the evolution settings section and its parameter cards.
362
+ *
363
+ * The section is registered through the platform's locale seat (`ctx.locale`),
364
+ * which is the same route the other out-of-repo client plugins take. The earlier
365
+ * note in this package's README — that an outside package cannot bind the locale
366
+ * seat — is wrong: `locale.register(namespace, { zh, en })` is the documented
367
+ * call, and it is what makes the section title and its copy follow the UI
368
+ * language instead of shipping one hard-coded dictionary.
369
+ */
370
+ /** Locale namespace this bundle registers. */
371
+ const NS = "evolution-settings";
372
+ /** Chinese copy (the family's primary language). */
373
+ const zh = {
374
+ title: "自进化",
375
+ subtitle: "审查/记忆/策展/技能四个命名空间的参数可在用户层覆盖;此处只列可改项,其余仍由部署面决定。",
376
+ overridden: "用户",
377
+ deployment: "部署",
378
+ loading: "正在读取设置文档…",
379
+ unavailable: "这个命名空间没有宿主提供服务(对应插件行未挂载)",
380
+ readonly: "本次会话的设置文档是只读的(内存模式)",
381
+ apply: "保存",
382
+ reset: "恢复部署默认",
383
+ empty: "这个命名空间没有可改项",
384
+ changed: "{n} 项已改",
385
+ discard: "放弃修改",
386
+ saving: "保存中…",
387
+ refused: "写入未被接受——宿主的校验拒绝了它(值保持原样)。原因见 /evolution params 或 /evolution doctor。",
388
+ cardReview: "审查",
389
+ cardMemory: "记忆",
390
+ cardToolMemory: "记忆工具",
391
+ cardCurator: "策展",
392
+ cardSkills: "技能"
393
+ };
394
+ /** English copy, key for key with {@link zh}. */
395
+ const en = {
396
+ title: "Self-evolution",
397
+ subtitle: "Parameters of the review, memory, curation and skill namespaces may be overridden per user; only the writable ones are listed here, everything else still comes from the deployment.",
398
+ overridden: "user",
399
+ deployment: "deployment",
400
+ loading: "reading the settings document…",
401
+ unavailable: "this namespace is not served by the Host (its owning plugin row is not mounted)",
402
+ readonly: "the settings document is read-only in this session (memory mode)",
403
+ apply: "Save",
404
+ reset: "Reset to deployment",
405
+ empty: "no writable field in this namespace",
406
+ changed: "{n} changed",
407
+ discard: "Discard",
408
+ saving: "Saving…",
409
+ refused: "The write was not accepted — the Host refused it and the value is unchanged. See /evolution params or /evolution doctor for the reason.",
410
+ cardReview: "Review",
411
+ cardMemory: "Memory",
412
+ cardToolMemory: "Memory tools",
413
+ cardCurator: "Curation",
414
+ cardSkills: "Skills"
415
+ };
416
+ /**
417
+ * Card titles per settings namespace. A namespace without an entry falls back to
418
+ * its raw name, so a Host that registers a new namespace still renders — the
419
+ * title is presentation, the namespace is the join key.
420
+ */
421
+ const NAMESPACE_TITLES = Object.freeze({
422
+ "evolution-review": "cardReview",
423
+ "evolution-memory": "cardMemory",
424
+ "evolution-tool-memory": "cardToolMemory",
425
+ "evolution-curator": "cardCurator",
426
+ "evolution-skills": "cardSkills"
427
+ });
428
+ /**
429
+ * Resolve one message without the locale seat (tests and the fallback path).
430
+ * @param key - the message key.
431
+ * @param language - which dictionary to read; defaults to Chinese.
432
+ * @returns the copy this bundle carries.
433
+ */
434
+ function message(key, language = "zh") {
435
+ return language === "zh" ? zh[key] : en[key];
436
+ }
437
+ //#endregion
438
+ //#region src/client/settle.ts
439
+ /**
440
+ * Structural equality for the values a parameter can hold.
441
+ * @param a - one side of the comparison.
442
+ * @param b - the other side.
443
+ * @returns true when the two are the same value.
444
+ */
445
+ function sameValue(a, b) {
446
+ if (Object.is(a, b)) return true;
447
+ if (typeof a !== typeof b || a === null || b === null) return false;
448
+ return JSON.stringify(a) === JSON.stringify(b);
449
+ }
450
+ /**
451
+ * Whether every staged write is visible in the user layer.
452
+ * @param pending - the writes awaiting a verdict.
453
+ * @param user - the user layer of the post-write snapshot; a non-section reads as empty.
454
+ * @returns true when each staged id now holds the value that was requested.
455
+ */
456
+ function landedWrites(pending, user) {
457
+ const section = typeof user === "object" && user !== null && !Array.isArray(user) ? user : {};
458
+ return pending.every((entry) => sameValue(section[entry.id], entry.want));
459
+ }
460
+ //#endregion
195
461
  //#region src/client/ParamCard.ts
196
462
  /**
197
463
  * One namespace's parameter card.
@@ -199,48 +465,100 @@ window.__ModuleLoader__.load({
199
465
  * Structural local types on purpose: this package is distributed outside the
200
466
  * platform repository and a value import of another plugin is forbidden, so the
201
467
  * settings scope and its snapshot are declared by the shape the renderer hands
202
- * over (`getSnapshot`/`subscribe`, status/value/user/writable).
468
+ * over (\`getSnapshot\`/\`subscribe\`, status/value/user/writable).
469
+ *
470
+ * Layout and behaviour follow the platform's own settings fields: a collapsed card
471
+ * header (namespace title, change count, chevron), then one block per field —
472
+ * label with its unit and source chip, a control HOLDING THE CURRENT VALUE, the
473
+ * Chinese hint — and one 放弃修改/保存 pair at the card's foot. The control is
474
+ * typed from the registry (\`control\`), so booleans are switches, enums are
475
+ * selects and numbers are numeric inputs with the unit shown beside the label.
203
476
  */
204
- /** One cell's text: absent reads as a dash, containers as JSON. */
477
+ /** One cell's text: absent reads as an empty control, containers as JSON. */
205
478
  function format(value) {
206
- if (value === void 0) return "—";
479
+ if (value === void 0) return "";
207
480
  if (typeof value === "string") return value;
208
481
  return JSON.stringify(value);
209
482
  }
210
- /** Parse one typed value the way the command surface does: JSON, else raw text. */
211
- function parse(draft) {
212
- const text = draft.trim();
483
+ /**
484
+ * Parse one edited value into what the scope should receive.
485
+ * @param control - the registry's control kind for the field.
486
+ * @param text - the text the operator left in the control.
487
+ * @returns the value handed to \`set\`; a string stays raw so the Host's own
488
+ * validation produces the error message the operator reads.
489
+ */
490
+ function parseFor(control, text) {
491
+ const trimmed = text.trim();
492
+ if (control === "switch") return trimmed === "true";
493
+ if (control === "number") {
494
+ const parsed = Number(trimmed);
495
+ return trimmed !== "" && Number.isFinite(parsed) ? parsed : trimmed;
496
+ }
213
497
  try {
214
- return JSON.parse(text);
498
+ return JSON.parse(trimmed);
215
499
  } catch {
216
- return text;
500
+ return trimmed;
217
501
  }
218
502
  }
219
503
  /** Whether a value is a plain section object. */
220
504
  function isSection(value) {
221
505
  return typeof value === "object" && value !== null && !Array.isArray(value);
222
506
  }
223
- /** One parameter row: current value, source badge, write and reset controls. */
224
- function FieldRow(props) {
225
- const [draft, setDraft] = (0, react.useState)("");
226
- return (0, react.createElement)("div", { className: "evolution-param-row" }, (0, react.createElement)("code", null, props.field.id), (0, react.createElement)("span", { className: "evolution-param-value" }, format(props.value)), (0, react.createElement)("span", { className: "evolution-param-source" }, props.overridden ? props.t("overridden") : props.t("deployment")), (0, react.createElement)("input", {
227
- value: draft,
507
+ /** One field's control, typed from the registry. */
508
+ function FieldControl(props) {
509
+ const { field, text } = props;
510
+ const common = {
511
+ id: "evolution-param-" + field.id,
228
512
  disabled: props.disabled,
229
- placeholder: props.field.doc,
513
+ className: "evolution-param-input"
514
+ };
515
+ if (field.control === "switch") return (0, react.createElement)("input", {
516
+ ...common,
517
+ className: "evolution-param-check",
518
+ type: "checkbox",
519
+ checked: text === "true",
230
520
  onChange: (event) => {
231
- setDraft(event.target.value);
521
+ props.onChange(field.id, String(event.target.checked));
232
522
  }
233
- }), (0, react.createElement)("button", {
234
- disabled: props.disabled || draft.trim() === "",
235
- onClick: () => {
236
- props.write(props.field.id, parse(draft));
523
+ });
524
+ if (field.control === "select") return (0, react.createElement)("select", {
525
+ ...common,
526
+ className: "evolution-param-select",
527
+ value: text,
528
+ onChange: (event) => {
529
+ props.onChange(field.id, event.target.value);
530
+ }
531
+ }, [field.values.includes(text) || text === "" ? null : (0, react.createElement)("option", {
532
+ key: text,
533
+ value: text
534
+ }, text), ...field.values.map((value) => (0, react.createElement)("option", {
535
+ key: value,
536
+ value
537
+ }, value))]);
538
+ return (0, react.createElement)("input", {
539
+ ...common,
540
+ type: field.control === "number" ? "number" : "text",
541
+ value: text,
542
+ onChange: (event) => {
543
+ props.onChange(field.id, event.target.value);
237
544
  }
238
- }, props.t("apply")), props.overridden ? (0, react.createElement)("button", {
545
+ });
546
+ }
547
+ /** One parameter field: label, control, hint, and its reset-to-deployment action. */
548
+ function FieldBlock(props) {
549
+ const { field, t } = props;
550
+ const unit = field.unit === "" ? null : (0, react.createElement)("span", { className: "evolution-param-unit" }, "(" + field.unit + ")");
551
+ return (0, react.createElement)("div", { className: "evolution-param-field" }, (0, react.createElement)("div", { className: "evolution-param-label" }, (0, react.createElement)("label", { htmlFor: "evolution-param-" + field.id }, field.label === "" ? field.id : field.label), unit, (0, react.createElement)("span", {
552
+ className: "evolution-param-source",
553
+ "data-user": props.overridden ? "true" : "false"
554
+ }, props.overridden ? t("overridden") : t("deployment"))), FieldControl(props), (0, react.createElement)("p", { className: "evolution-param-hint" }, field.hint === "" ? field.doc : field.hint), props.overridden ? (0, react.createElement)("div", { className: "evolution-param-actions" }, (0, react.createElement)("button", {
555
+ type: "button",
556
+ className: "evolution-param-button",
239
557
  disabled: props.disabled,
240
558
  onClick: () => {
241
- props.clear(props.field.id);
559
+ props.clear(field.id);
242
560
  }
243
- }, props.t("reset")) : null);
561
+ }, t("reset"))) : null);
244
562
  }
245
563
  /**
246
564
  * Render one namespace's card.
@@ -248,78 +566,264 @@ window.__ModuleLoader__.load({
248
566
  * @returns the card.
249
567
  */
250
568
  function ParamCard(props) {
251
- const { t, fields, write, clear } = props;
569
+ const { t, fields, write, clear, namespace } = props;
570
+ const [open, setOpen] = (0, react.useState)(false);
571
+ const [draft, setDraft] = (0, react.useState)({});
572
+ const [busy, setBusy] = (0, react.useState)(false);
573
+ const [error, setError] = (0, react.useState)("");
574
+ const [pending, setPending] = (0, react.useState)(null);
252
575
  const snapshot = props.useParamSection((state) => state);
253
- if (snapshot.status === "loading") return (0, react.createElement)("p", null, t("loading"));
254
- if (snapshot.status === "unavailable") return (0, react.createElement)("p", null, t("unavailable"));
576
+ if (snapshot.status === "loading") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("loading"));
577
+ if (snapshot.status === "unavailable") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("unavailable"));
255
578
  const user = isSection(snapshot.user) ? snapshot.user : {};
256
579
  const value = isSection(snapshot.value) ? snapshot.value : {};
257
580
  const disabled = !snapshot.writable;
258
- return (0, react.createElement)("section", { className: "evolution-params" }, (0, react.createElement)("h4", null, t("title") + " — " + props.namespace), disabled ? (0, react.createElement)("p", null, t("readonly")) : null, fields.length === 0 ? (0, react.createElement)("p", null, t("empty")) : fields.map((field) => (0, react.createElement)(FieldRow, {
581
+ const locked = disabled || busy;
582
+ const titleKey = NAMESPACE_TITLES[namespace];
583
+ const title = titleKey === void 0 ? namespace : t(titleKey);
584
+ const changed = fields.filter((field) => Object.hasOwn(user, field.id)).length;
585
+ const textOf = (field) => draft[field.id] ?? format(value[field.id]);
586
+ const dirty = fields.filter((field) => draft[field.id] !== void 0 && draft[field.id] !== format(value[field.id]));
587
+ const change = (id, next) => {
588
+ setDraft({
589
+ ...draft,
590
+ [id]: next
591
+ });
592
+ };
593
+ /**
594
+ * Settle a staged write against the Host's answer.
595
+ *
596
+ * A resolved write promise is not the verdict: both settings scopes this bundle runs
597
+ * on (the shell's own controller and the bridge variant) finish their recovery read
598
+ * BEFORE it resolves, so a value the Host refused is simply still the previous one.
599
+ * Presence in the user layer therefore cannot answer the question — on a field the
600
+ * operator had already overridden, a refused value's key is present as well. The
601
+ * verdict asks whether each staged id now holds the value this card requested.
602
+ * The check lives in an effect, not in the save callback, because the raw snapshot is
603
+ * reachable only through the render-time seat — the face's `hooks` compartment never
604
+ * reaches the component.
605
+ */
606
+ (0, react.useEffect)(() => {
607
+ if (pending === null) return;
608
+ setPending(null);
609
+ setBusy(false);
610
+ if (landedWrites(pending, snapshot.user)) setDraft({});
611
+ else setError(t("refused"));
612
+ }, [
613
+ pending,
614
+ snapshot.user,
615
+ t
616
+ ]);
617
+ const save = () => {
618
+ setBusy(true);
619
+ setError("");
620
+ (async () => {
621
+ try {
622
+ for (const field of dirty) await write(field.id, parseFor(field.control, textOf(field)));
623
+ setPending(dirty.map((field) => ({
624
+ id: field.id,
625
+ want: parseFor(field.control, textOf(field))
626
+ })));
627
+ } catch (caught) {
628
+ setBusy(false);
629
+ setError(caught instanceof Error && caught.message !== "" ? caught.message : t("refused"));
630
+ }
631
+ })();
632
+ };
633
+ return (0, react.createElement)("section", { className: "evolution-param-card" }, (0, react.createElement)("button", {
634
+ type: "button",
635
+ className: "evolution-param-head",
636
+ "aria-expanded": open ? "true" : "false",
637
+ onClick: () => {
638
+ setOpen(!open);
639
+ }
640
+ }, (0, react.createElement)("span", { className: "evolution-param-card-title" }, title), changed === 0 ? null : (0, react.createElement)("span", { className: "evolution-param-count" }, t("changed").replace("{n}", String(changed))), (0, react.createElement)("span", { className: "evolution-param-chevron" }, open ? "▲" : "▼")), open ? (0, react.createElement)("div", { className: "evolution-param-body" }, disabled ? (0, react.createElement)("p", { className: "evolution-param-note" }, t("readonly")) : null, fields.length === 0 ? (0, react.createElement)("p", { className: "evolution-param-note" }, t("empty")) : fields.map((field) => (0, react.createElement)(FieldBlock, {
259
641
  key: field.id,
260
642
  field,
261
- value: value[field.id],
643
+ text: textOf(field),
262
644
  overridden: Object.hasOwn(user, field.id),
263
- disabled,
645
+ disabled: locked,
264
646
  t,
265
- write,
647
+ onChange: change,
266
648
  clear
267
- })));
649
+ })), error === "" ? null : (0, react.createElement)("p", { className: "evolution-param-error" }, error), fields.length === 0 ? null : (0, react.createElement)("div", { className: "evolution-param-footer" }, (0, react.createElement)("button", {
650
+ type: "button",
651
+ className: "evolution-param-button",
652
+ disabled: busy || dirty.length === 0,
653
+ onClick: () => {
654
+ setDraft({});
655
+ setError("");
656
+ }
657
+ }, t("discard")), (0, react.createElement)("button", {
658
+ type: "button",
659
+ className: "evolution-param-button",
660
+ "data-primary": "true",
661
+ disabled: disabled || busy || dirty.length === 0,
662
+ onClick: save
663
+ }, busy ? t("saving") : t("apply")))) : null);
268
664
  }
269
665
  //#endregion
270
- //#region src/client/messages.ts
666
+ //#region src/client/seam.ts
667
+ /** Our card slot, hosted by the section this bundle registers. */
668
+ const CARD_SLOT = "evolution.namespace.card";
669
+ //#endregion
670
+ //#region src/client/SettingsSection.ts
271
671
  /**
272
- * Copy for the evolution parameter cards.
672
+ * The section shell: title, one-line explanation, then one card per namespace.
273
673
  *
274
- * The bundle carries its own dictionary: this package is distributed outside the
275
- * platform repository, whose typed locale seats are declared per in-repo
276
- * namespace. Components never inline a string — the card receives `t` through its
277
- * inject face, which is where a locale-service binding would plug in later.
674
+ * This is the shape the platform's own settings sections use — a heading plus the
675
+ * child slots it declared in its registration. The cards themselves are separate
676
+ * registrations keyed by namespace, so a namespace the Host does not serve simply
677
+ * renders nothing and a deployment that drops this row loses the whole section.
678
+ * @module @lmzhen/dsh-evolution-settings-ui/client
278
679
  */
279
- /** Message keys the cards render. */
280
- const MESSAGES = {
281
- title: "Evolution parameters",
282
- overridden: "user override",
283
- deployment: "deployment value",
284
- loading: "reading the settings document…",
285
- unavailable: "this namespace is not served by the Host (its owning plugin row is not mounted)",
286
- readonly: "the settings document is read-only in this session (memory mode)",
287
- apply: "Apply",
288
- reset: "Reset to deployment",
289
- empty: "no writable field in this namespace"
290
- };
291
680
  /**
292
- * Resolve one message.
293
- * @param key - the message key.
294
- * @returns the copy this bundle carries.
681
+ * Render the section.
682
+ * @param face - the shell's inject face: translator and child-slot renderer.
683
+ * @returns the section element.
295
684
  */
296
- function message(key) {
297
- return MESSAGES[key];
685
+ function SettingsSection(face) {
686
+ return (0, react.createElement)("div", { className: "evolution-params" }, (0, react.createElement)("h2", { className: "evolution-params-title" }, face.t("title")), (0, react.createElement)("p", { className: "evolution-params-subtitle" }, face.t("subtitle")), ...CLIENT_PARAM_SECTIONS.map((section_) => (0, react.createElement)("div", {
687
+ key: section_.namespace,
688
+ className: "evolution-params-group"
689
+ }, face.renderSlot(CARD_SLOT, {}, { entryKey: section_.namespace }))));
690
+ }
691
+ //#endregion
692
+ //#region src/client/styles.ts
693
+ /**
694
+ * The section's stylesheet.
695
+ *
696
+ * The platform's own client bundles compile CSS Modules into a string and inject
697
+ * it once behind a \`<style data-plugin-css="<pkg>/<file>">\` tag; this bundle is
698
+ * built outside that pipeline, so it carries its own string and injects it the
699
+ * same way. Every rule reads the platform's design tokens (\`--dsw-alias-*\`), so
700
+ * the section follows the light and dark themes without owning a colour, and the
701
+ * field metrics (12px/6px padding-gap, 13px label, 12px hint) match the ones the
702
+ * platform's own settings fields use.
703
+ * @module @lmzhen/dsh-evolution-settings-ui/client
704
+ */
705
+ /** Tag id that makes the injection idempotent. */
706
+ const CSS_TAG_ID = "@lmzhen/dsh-evolution-settings-ui/settings.css";
707
+ /** The stylesheet the section injects once. */
708
+ const CSS = [
709
+ ".evolution-params{display:flex;flex-direction:column}",
710
+ ".evolution-params-title{margin:0 0 4px;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:600;line-height:1.5}",
711
+ ".evolution-params-subtitle{margin:0 0 16px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
712
+ ".evolution-param-card{border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);margin-bottom:12px}",
713
+ ".evolution-param-head{display:flex;align-items:center;gap:8px;width:100%;padding:12px 14px;background:none;border:0;text-align:left;cursor:pointer;color:inherit;font:inherit}",
714
+ ".evolution-param-head:hover{background:var(--dsw-alias-interactive-bg-hover)}",
715
+ ".evolution-param-card-title{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5}",
716
+ ".evolution-param-count{color:var(--dsw-alias-brand-primary);font-size:12px;line-height:1.5}",
717
+ ".evolution-param-chevron{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
718
+ ".evolution-param-body{padding:0 14px 12px;border-top:.5px solid var(--dsw-alias-border-l3)}",
719
+ ".evolution-param-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}",
720
+ ".evolution-param-field+.evolution-param-field{border-top:.5px solid var(--dsw-alias-border-l4)}",
721
+ ".evolution-param-label{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5}",
722
+ ".evolution-param-unit{color:var(--dsw-alias-label-tertiary);font-weight:400}",
723
+ ".evolution-param-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
724
+ ".evolution-param-value{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}",
725
+ ".evolution-param-input{width:100%;box-sizing:border-box;padding:8px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}",
726
+ ".evolution-param-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}",
727
+ ".evolution-param-input:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}",
728
+ ".evolution-param-source{display:inline-flex;align-items:center;height:20px;padding:0 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px}",
729
+ ".evolution-param-source[data-user=\"true\"]{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}",
730
+ ".evolution-param-actions{display:flex;justify-content:flex-end;gap:8px;padding-top:12px}",
731
+ ".evolution-param-button{padding:6px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;cursor:pointer}",
732
+ ".evolution-param-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}",
733
+ ".evolution-param-button[data-primary=\"true\"]{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}",
734
+ ".evolution-param-button[data-primary=\"true\"]:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}",
735
+ ".evolution-param-button:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}",
736
+ ".evolution-param-select{width:100%;box-sizing:border-box;padding:8px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}",
737
+ ".evolution-param-select:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}",
738
+ ".evolution-param-select:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}",
739
+ ".evolution-param-check{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}",
740
+ ".evolution-param-check:disabled{cursor:default;opacity:.5}",
741
+ ".evolution-param-footer{display:flex;justify-content:flex-end;gap:8px;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l3)}",
742
+ ".evolution-param-note{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
743
+ ".evolution-param-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5}"
744
+ ].join("");
745
+ /**
746
+ * Inject the stylesheet once per document.
747
+ * @param host - the document to inject into; defaults to the global one.
748
+ */
749
+ function injectStyles(host) {
750
+ const doc = host ?? globalThis.document;
751
+ if (doc === void 0) return;
752
+ if (doc.querySelector("style[data-plugin-css=\"@lmzhen/dsh-evolution-settings-ui/settings.css\"]") !== null) return;
753
+ const tag = doc.createElement("style");
754
+ tag.dataset.plugin = "@lmzhen/dsh-evolution-settings-ui";
755
+ tag.dataset.pluginCss = CSS_TAG_ID;
756
+ tag.textContent = CSS;
757
+ doc.head.appendChild(tag);
298
758
  }
299
759
  //#endregion
300
760
  //#region src/client/index.ts
301
- /** Required client services: the slot registry and the settings transport. */
302
- const inject = ["slots", "settingsScope"];
761
+ /** Required client services: the slot registry, the settings transport, the locale seat. */
762
+ const inject = [
763
+ "slots",
764
+ "settingsScope",
765
+ "locale"
766
+ ];
767
+ /** Section id and nav order (the platform's own sections sit at 0/10/15/20). */
768
+ const SECTION_ID = "evolution";
769
+ const SECTION_ORDER = 25;
303
770
  /**
304
- * Register one card per generated namespace section.
771
+ * Register the locale namespace, the section and its cards.
305
772
  * @param ctx - client cordis context.
306
773
  */
307
774
  function apply(ctx) {
775
+ injectStyles();
308
776
  const seam = ctx;
309
- seam.slots.inject("settings.plugin.item", function* () {
777
+ let registered = false;
778
+ ctx.effect(() => {
779
+ try {
780
+ const dispose = seam.locale.register(NS, {
781
+ zh,
782
+ en
783
+ });
784
+ registered = true;
785
+ return dispose;
786
+ } catch {
787
+ return () => {};
788
+ }
789
+ }, "evolution-settings: dictionaries");
790
+ let bound = null;
791
+ const t = (key) => {
792
+ if (!registered) return message(key);
793
+ if (bound === null) try {
794
+ bound = seam.locale.bind(NS);
795
+ } catch {
796
+ return message(key);
797
+ }
798
+ return bound(key);
799
+ };
800
+ seam.slots.inject("settings.section", function* () {
801
+ yield seam.slots.register({
802
+ name: "settings.section",
803
+ id: SECTION_ID,
804
+ order: 25,
805
+ label: () => t("title"),
806
+ locale: NS,
807
+ children: { [CARD_SLOT]: {
808
+ kind: "keyed",
809
+ scope: "root"
810
+ } }
811
+ }, SettingsSection);
812
+ });
813
+ seam.slots.inject(CARD_SLOT, function* () {
310
814
  for (const section of CLIENT_PARAM_SECTIONS) {
311
815
  const scope = seam.settingsScope.bind({ namespace: section.namespace });
312
816
  const source = scope;
313
817
  const face = () => ({
314
818
  namespace: section.namespace,
315
819
  fields: section.fields,
316
- t: (key) => message(key),
820
+ t,
317
821
  write: (field, value) => scope.set(field, value),
318
822
  clear: (field) => scope.unset(field),
319
823
  hooks: { paramSection: source }
320
824
  });
321
825
  yield seam.slots.register({
322
- name: "settings.plugin.item",
826
+ name: CARD_SLOT,
323
827
  key: section.namespace,
324
828
  inject: face
325
829
  }, ParamCard);
@@ -327,6 +831,8 @@ window.__ModuleLoader__.load({
327
831
  });
328
832
  }
329
833
  //#endregion
834
+ exports.SECTION_ID = SECTION_ID;
835
+ exports.SECTION_ORDER = SECTION_ORDER;
330
836
  exports.apply = apply;
331
837
  exports.inject = inject;
332
838