@starwish-ai/xingyuan-dsh 0.6.1 → 0.6.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.3] - 2026-09-06
9
+
10
+ ### Changed
11
+ - **Settings page information architecture**: the former single "chat preferences"
12
+ card is split into two focused cards — "Confirmation before writes" (master
13
+ switch + six category rows + an always-on badge for deletes) and "Chat
14
+ preferences" (memory injection limit + confirmation-card language). Toggle and
15
+ enum settings now follow the settings-row convention used by OS settings pages
16
+ (label + description left, control right, hairline dividers); form fields keep
17
+ the label-above layout. Both themes and narrow viewports verified against the
18
+ static style mock.
19
+ - Confirmation-card language uses a segmented control (consistent with coach
20
+ style / tab visibility) instead of a dropdown; tab visibility chips gain a ✓
21
+ prefix to distinguish multi-select chips from single-select segmented controls.
22
+ - The locked delete category shows an "Always on" badge instead of a disabled
23
+ checkbox: a disabled control read as "broken", the badge states the fact.
24
+ - Coach style hint is static guidance (the segmented buttons already carry the
25
+ current selection).
26
+
27
+ ### Fixed
28
+ - Disabled controls now look disabled: custom toggles and segmented buttons get
29
+ explicit grey-out styling when the settings namespace is read-only or a write
30
+ is in flight (they ignore native disabled rendering).
31
+ - With the write-confirmation master switch off, the locked delete row is no
32
+ longer greyed out — it is not governed by the master switch, and the dimmed
33
+ label contradicted its "Always on" badge.
34
+ - Entering an out-of-range memory injection limit (e.g. 300) shows an
35
+ informational note that the nearest bound was saved, instead of an "enter an
36
+ integer between 5 and 200" error while the clamped value had in fact been
37
+ saved. Genuinely invalid input (non-integers) keeps the inline error.
38
+ - Free-standing hint lines (namespace notice, trailing data note) align with the
39
+ card text column instead of hugging the container's left edge.
40
+ - Memory-limit input: `autocomplete="off"` and tabular numerals; corrected a
41
+ stale style comment.
42
+
43
+ ## [0.6.2] - 2026-09-06
44
+
45
+ ### Added
46
+ - **Fine-grained write-confirmation settings**: the master switch ("写操作二次确认")
47
+ now expands into six per-category detail toggles — create (incl. micro-action
48
+ breakdown), check-in, cancel check-in, claim, edit, memory save. Detail rows stay
49
+ visible under the master switch and grey out (explicit disabled styling) when it is
50
+ off; the delete row remains locked (always confirmed, no off switch). Defaults
51
+ reproduce the previous matrix: create / check-in / cancel check-in ask; claim /
52
+ edit / memory save run on your instruction. Claim, edit and memory-save tools
53
+ gained real confirmation hooks (600 s HITL timeout, validation before asking,
54
+ cancel exits without writing). Chat-side HITL only — page dialogs are unchanged.
55
+ Zero migration: absent settings resolve to the defaults.
56
+
57
+ ### Changed
58
+ - `rename_wish_category` validates before asking: renaming a non-existent category
59
+ errors up front instead of after a confirmation; renaming a color-override-only
60
+ category (zero wishes) now succeeds and reports "0 wishes" instead of falsely
61
+ erroring "category does not exist" after the override had already been migrated.
62
+
63
+ ### Fixed
64
+ - sqlite backend `close()` now actually releases the database handle (was an empty
65
+ implementation), so hot-reload / unload no longer keeps the file locked.
66
+ - Bundle main row declares the previously missing
67
+ `storageBackendServiceKey('sqlite')` injection key.
68
+ - StaleBanner text contrast: new `--xyd-on-warn` token pair reaches WCAG 4.5:1 on the
69
+ warn background in both light and dark themes (locked by a WCAG computation test).
70
+ - README confirmation wording no longer contradicts AGENTS.md §5.5.
71
+
8
72
  ## [0.6.1] - 2026-09-05
9
73
 
10
74
  ### Added
package/README.en-US.md CHANGED
@@ -18,7 +18,7 @@ XingYuan is a wish & habit-building companion, shipped as a DeepSeek Harness plu
18
18
  - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly; shown only in XingYuan-preset sessions by default, switchable to always show/hide and per-tab in Settings
19
19
  - **Memory**: important memories injected into context (limit configurable), full CRUD
20
20
  - **Charts**: 15 chart types rendered as cards
21
- - **Safe writes**: write operations confirmed by default (configurable); deletion always confirmed
21
+ - **Safe writes**: write confirmation is per-category (create / check-in / undo / claim / edit / save memory); deletion always confirms and cannot be turned off; defaults match previous versions
22
22
  - **Theming**: light/dark follows the app theme
23
23
 
24
24
  Tools are only mounted on sessions using the XingYuan preset; other sessions are unaffected.
@@ -41,7 +41,7 @@ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall
41
41
  | --- | --- |
42
42
  | Coach style | Gentle / humorous / strict |
43
43
  | User profile | Nickname, occupation, interests |
44
- | Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
44
+ | Write confirmation | Master switch + per-category toggles (create / check-in / undo / claim / edit / save memory); deletion always confirms and cannot be turned off |
45
45
  | Memory injection limit | Max memories injected per turn |
46
46
  | Tab visibility | Three modes (follow session / always show / always hide) plus per-tab toggles; defaults to follow session |
47
47
  | Confirm card language | Language of the in-chat confirmation card (Chinese / English; the platform does not expose UI language to plugins, defaults to Chinese) |
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  默认仅星愿预设的会话显示,可在设置里切换「始终显示 / 始终隐藏」并按标签勾选
21
21
  - **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
22
22
  - **图表**:15 种统计图表自动渲染为卡片
23
- - **安全确认**:写操作默认二次确认(可关闭),删除始终确认
23
+ - **安全确认**:写操作二次确认按类目细化(创建 / 打卡 / 取消打卡 / 领取 / 修改 / 保存记忆),删除始终确认不可关闭;默认与此前版本一致
24
24
  - **主题适配**:深浅色跟随应用主题
25
25
 
26
26
  工具只挂载在选择星愿 preset 的会话上,不影响其他会话。
@@ -43,7 +43,7 @@ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
43
43
  | --- | --- |
44
44
  | 教练风格 | 温柔 / 幽默 / 严格 |
45
45
  | 用户画像 | 昵称、职业、兴趣 |
46
- | 写操作二次确认 | 创建 / 打卡类写操作是否确认(删除始终确认) |
46
+ | 写操作二次确认 | 总开关 + 六个类目明细(创建 / 打卡 / 取消打卡 / 领取 / 修改 / 保存记忆)是否确认;删除始终确认、不可关闭 |
47
47
  | 记忆注入上限 | 每次注入上下文的记忆条数上限 |
48
48
  | 标签页显示 | 三态模式(跟随会话 / 始终显示 / 始终隐藏)+ 六个标签逐个勾选;默认跟随会话 |
49
49
  | 确认卡语言 | 对话中确认卡的显示语言(中文 / English;平台不向插件暴露界面语言,默认中文) |
@@ -255,30 +255,43 @@ export const ZH_DICT = {
255
255
  'settings.coach.gentle': '温柔型',
256
256
  'settings.coach.strict': '严格型',
257
257
  'settings.coach.humorous': '幽默型',
258
- 'settings.coach.current': '当前:{label}。决定对话语气与人设,也可在对话中说「对我严格一点」。',
258
+ 'settings.coach.hint': '决定对话语气与人设,也可在对话中说「对我严格一点」。',
259
259
  'settings.coach.saved': '教练风格已更新,对后续对话生效',
260
260
  'settings.coach.loadFailed': '加载失败:{error}',
261
261
  'settings.profile.title': '昵称与画像',
262
262
  'settings.profile.nickname': '昵称',
263
263
  'settings.profile.occupation': '职业',
264
264
  'settings.profile.interests': '兴趣',
265
- 'settings.profile.nicknamePlaceholder': '昵称:希望被怎么称呼(留空清除)',
266
- 'settings.profile.occupationPlaceholder': '职业(留空清除)',
267
- 'settings.profile.interestsPlaceholder': '兴趣:用顿号或逗号分隔(如 阅读、跑步)',
265
+ 'settings.profile.nicknamePlaceholder': '希望被怎么称呼',
266
+ 'settings.profile.occupationPlaceholder': '如:教师、产品经理',
267
+ 'settings.profile.interestsPlaceholder': '如:阅读、跑步',
268
268
  'settings.profile.save': '保存画像',
269
269
  'settings.profile.saving': '保存中…',
270
270
  'settings.profile.saved': '画像已保存',
271
- 'settings.profile.sharedHint': '与对话侧共享同一份档案;对话里说「叫我小星」也会更新。',
271
+ 'settings.profile.sharedHint': '留空的字段会被清除;画像与对话侧共享,对话里说「叫我小星」也会更新。',
272
272
  'settings.pref.title': '对话偏好',
273
273
  'settings.pref.unavailable': '当前连接不支持持久化偏好(远程或临时模式),以下选项不可用。',
274
274
  'settings.pref.loading': '偏好加载中…',
275
275
  'settings.pref.notRegistered': '偏好暂不可用(组件未就绪),请稍后重试。',
276
276
  'settings.pref.readOnly': '当前设置为只读,无法保存修改。',
277
- 'settings.pref.confirmWrites': '写操作二次确认',
278
- 'settings.pref.confirmWritesHint': '创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。',
277
+ 'settings.confirm.title': '写操作确认',
278
+ 'settings.confirm.master': '总开关',
279
+ 'settings.pref.confirmWritesHint': '开启后按下方类目决定是否弹确认卡;关闭后除删除(始终确认)外一律直接执行。',
280
+ 'settings.pref.confirmOps.group': '确认类目',
281
+ 'settings.pref.confirmOps.create': '创建愿望/任务(含微行动拆解)',
282
+ 'settings.pref.confirmOps.checkin': '打卡',
283
+ 'settings.pref.confirmOps.cancelCheckin': '取消打卡',
284
+ 'settings.pref.confirmOps.claim': '领取任务',
285
+ 'settings.pref.confirmOps.update': '修改愿望/任务/分类',
286
+ 'settings.pref.confirmOps.memorySave': '保存记忆',
287
+ 'settings.pref.confirmOps.delete': '删除(含批量、微行动重开)',
288
+ 'settings.pref.confirmOps.locked': '始终开启',
289
+ 'settings.pref.confirmOps.deleteHint': '始终确认,不可关闭:删除不可恢复。',
290
+ 'settings.pref.confirmOps.hint': '类目明细在总开关开启时生效;默认与之前版本一致。',
279
291
  'settings.pref.memoryLimit': '记忆注入上限',
280
292
  'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
281
293
  'settings.pref.limitInvalid': '请输入 5-200 的整数',
294
+ 'settings.pref.limitClamped': '超出 5-200 范围,已按最近的边界值保存',
282
295
  'settings.pref.writeFailed': '保存未生效,请重试。',
283
296
  'settings.pref.confirmLang': '确认卡语言',
284
297
  'settings.pref.confirmLangHint': '对话中写操作确认卡与问题的显示语言。确认卡无法自动跟随界面语言,需在此选择;默认中文。',
@@ -674,30 +687,43 @@ const EN_DICT = {
674
687
  'settings.coach.gentle': 'Gentle',
675
688
  'settings.coach.strict': 'Strict',
676
689
  'settings.coach.humorous': 'Humorous',
677
- 'settings.coach.current': 'Current: {label}. Shapes the coaching tone; you can also say “be tougher on me” in chat.',
690
+ 'settings.coach.hint': 'Shapes the coaching tone; you can also say “be tougher on me” in chat.',
678
691
  'settings.coach.saved': 'Coach style updated; applies to future chats',
679
692
  'settings.coach.loadFailed': 'Failed to load: {error}',
680
693
  'settings.profile.title': 'Nickname & profile',
681
694
  'settings.profile.nickname': 'Nickname',
682
695
  'settings.profile.occupation': 'Occupation',
683
696
  'settings.profile.interests': 'Interests',
684
- 'settings.profile.nicknamePlaceholder': 'Nickname: how should I call you? (empty clears)',
685
- 'settings.profile.occupationPlaceholder': 'Occupation (empty clears)',
686
- 'settings.profile.interestsPlaceholder': 'Interests: separate with commas (e.g. reading, running)',
697
+ 'settings.profile.nicknamePlaceholder': 'How should I call you?',
698
+ 'settings.profile.occupationPlaceholder': 'e.g. teacher, product manager',
699
+ 'settings.profile.interestsPlaceholder': 'e.g. reading, running',
687
700
  'settings.profile.save': 'Save profile',
688
701
  'settings.profile.saving': 'Saving…',
689
702
  'settings.profile.saved': 'Profile saved',
690
- 'settings.profile.sharedHint': 'Shared with the chat side; saying “call me Star” in chat updates it too.',
703
+ 'settings.profile.sharedHint': 'Empty fields are cleared. The profile is shared with chat; saying “call me Star” updates it too.',
691
704
  'settings.pref.title': 'Chat preferences',
692
705
  'settings.pref.unavailable': 'This connection cannot persist preferences (remote or temporary mode); the options below are disabled.',
693
706
  'settings.pref.loading': 'Loading preferences…',
694
707
  'settings.pref.notRegistered': 'Preferences are unavailable right now (component not ready); please retry later.',
695
708
  'settings.pref.readOnly': 'Settings are read-only; changes cannot be saved.',
696
- 'settings.pref.confirmWrites': 'Confirm write actions',
697
- 'settings.pref.confirmWritesHint': 'Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.',
709
+ 'settings.confirm.title': 'Confirmation before writes',
710
+ 'settings.confirm.master': 'Master switch',
711
+ 'settings.pref.confirmWritesHint': 'When on, confirmations follow the categories below; when off, everything except deletes (always confirmed) runs directly.',
712
+ 'settings.pref.confirmOps.group': 'Confirmation categories',
713
+ 'settings.pref.confirmOps.create': 'Create wishes/tasks (incl. micro-action plans)',
714
+ 'settings.pref.confirmOps.checkin': 'Check-ins',
715
+ 'settings.pref.confirmOps.cancelCheckin': 'Cancel check-ins',
716
+ 'settings.pref.confirmOps.claim': 'Claiming tasks',
717
+ 'settings.pref.confirmOps.update': 'Editing wishes/tasks/categories',
718
+ 'settings.pref.confirmOps.memorySave': 'Saving memories',
719
+ 'settings.pref.confirmOps.delete': 'Deletes (incl. batch & restarts)',
720
+ 'settings.pref.confirmOps.locked': 'Always on',
721
+ 'settings.pref.confirmOps.deleteHint': 'Always confirms and cannot be turned off; deletes are irreversible.',
722
+ 'settings.pref.confirmOps.hint': 'Category switches apply while the master switch is on; defaults match previous versions.',
698
723
  'settings.pref.memoryLimit': 'Memory injection limit',
699
724
  'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved when you leave the field.',
700
725
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
726
+ 'settings.pref.limitClamped': 'Out of the 5-200 range; saved as the nearest allowed value.',
701
727
  'settings.pref.writeFailed': 'The change did not save; please try again.',
702
728
  'settings.pref.confirmLang': 'Confirm card language',
703
729
  'settings.pref.confirmLangHint': 'Language of the in-chat confirmation card and its question. The card cannot follow the interface language automatically, so pick it here; defaults to Chinese.',
@@ -6,7 +6,7 @@ import { getJson, postJson } from '../api.js';
6
6
  import { toastError } from '../ui.js';
7
7
  import { useXyT, activeLocale } from '../i18n.js';
8
8
  import { TAB_IDS } from '../../tab-policy.js';
9
- import { MEMORY_LIMIT_MAX, MEMORY_LIMIT_MIN, PREF_DEFAULTS, parseMemoryLimit, } from '../../pref-policy.js';
9
+ import { CONFIRM_OPS, MEMORY_LIMIT_MAX, MEMORY_LIMIT_MIN, PREF_DEFAULTS, normalizeConfirmOps, parseMemoryLimit, } from '../../pref-policy.js';
10
10
  const COACH_IDS = ['gentle', 'strict', 'humorous'];
11
11
  const COACH_KEYS = ['settings.coach.gentle', 'settings.coach.strict', 'settings.coach.humorous'];
12
12
  /** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
@@ -24,6 +24,15 @@ const TAB_LABEL_KEYS = {
24
24
  growth: 'tab.growth',
25
25
  memory: 'tab.memory',
26
26
  };
27
+ /** 确认类目的显示名键(与 pref-policy CONFIRM_OPS 一一对应)。 */
28
+ const CONFIRM_OP_LABEL_KEYS = {
29
+ create: 'settings.pref.confirmOps.create',
30
+ checkin: 'settings.pref.confirmOps.checkin',
31
+ cancelCheckin: 'settings.pref.confirmOps.cancelCheckin',
32
+ claim: 'settings.pref.confirmOps.claim',
33
+ update: 'settings.pref.confirmOps.update',
34
+ memorySave: 'settings.pref.confirmOps.memorySave',
35
+ };
27
36
  export function SettingsSection(props) {
28
37
  const t = useXyT();
29
38
  const scope = props.scope;
@@ -40,9 +49,10 @@ export function SettingsSection(props) {
40
49
  const [nicknameDraft, setNicknameDraft] = useState('');
41
50
  const [occupationDraft, setOccupationDraft] = useState('');
42
51
  const [interestsDraft, setInterestsDraft] = useState('');
43
- // 记忆注入上限:草稿态编辑(null=显示快照值),失焦才校验提交——避免逐键持久化与非法中间值死锁
52
+ // 记忆注入上限:草稿态编辑(null=显示快照值),失焦才校验提交——避免逐键持久化与非法中间值死锁。
53
+ // 错误态分两档:invalid=未保存的就地报错;clamped=越界已按边界值保存的说明性提示(不是错误)
44
54
  const [limitDraft, setLimitDraft] = useState(null);
45
- const [limitError, setLimitError] = useState(false);
55
+ const [limitError, setLimitError] = useState(undefined);
46
56
  // 记忆注入上限:写入在途的乐观值(与开关的 pendingToggle 同款,用于禁用控件并顶住回显,
47
57
  // 否则提交后到快照回折前会闪回旧值)
48
58
  const [pendingLimit, setPendingLimit] = useState(undefined);
@@ -52,6 +62,8 @@ export function SettingsSection(props) {
52
62
  const [savedMsg, setSavedMsg] = useState('');
53
63
  // 二次确认开关:本地乐观值(undefined=跟随远端快照)——写入在途时 UI 立即响应,失败回滚
54
64
  const [pendingToggle, setPendingToggle] = useState(undefined);
65
+ // 确认类目明细:乐观值为「合并后的完整对象」(每次写整体下发),失败回滚
66
+ const [pendingOps, setPendingOps] = useState(undefined);
55
67
  // 标签页显隐:模式与勾选各自乐观(写入在途时禁用对应控件,失败回滚 + toast)
56
68
  const [pendingMode, setPendingMode] = useState(undefined);
57
69
  const [pendingHidden, setPendingHidden] = useState(undefined);
@@ -67,6 +79,8 @@ export function SettingsSection(props) {
67
79
  // 开关显示值:乐观本地值优先(写入在途),否则远端快照,值缺席回落 schema 默认。
68
80
  // 不可写成 `!== false`——那样「值未知」会被渲染成「已开启」,安全策略类开关尤其不能撒谎。
69
81
  const confirmWrites = pendingToggle ?? snap.value?.confirmWrites ?? PREF_DEFAULTS.confirmWrites;
82
+ // 确认类目显示值:乐观优先 → 快照(脏键容错回落默认)→ 全默认
83
+ const confirmOps = pendingOps ?? normalizeConfirmOps(snap.value?.confirmOps);
70
84
  // 偏好区提示:判定顺序不可换——memory 模式下 status 同样是 unavailable,
71
85
  // 先判 mode 才不会把「远程/临时模式」误报成「命名空间未就绪」
72
86
  const prefNoticeKey = snap.status === 'loading' ? 'settings.pref.loading'
@@ -165,7 +179,13 @@ export function SettingsSection(props) {
165
179
  const verifyWritten = (seq, field, value) => {
166
180
  if (seq !== writeSeqRef.current)
167
181
  return;
168
- if (scope.getSnapshot().value?.[field] !== value)
182
+ const current = scope.getSnapshot().value?.[field];
183
+ // 对象字段(confirmOps)按 CONFIRM_OPS 逐键比对(对键序不敏感;JSON.stringify
184
+ // 比对会依赖两侧对象键序巧合一致);标量直接全等
185
+ const same = typeof value === 'object'
186
+ ? CONFIRM_OPS.every((key) => current?.[key] === value[key])
187
+ : current === value;
188
+ if (!same)
169
189
  toastError(new Error(t('settings.pref.writeFailed')));
170
190
  };
171
191
  /** 标签页显隐命名空间的写后校验(与 verifyWritten 同款,命名空间独立故写队列与序号独立)。 */
@@ -195,10 +215,30 @@ export function SettingsSection(props) {
195
215
  toastError(err);
196
216
  });
197
217
  };
218
+ /**
219
+ * 切换单个确认类目:整体写合并后的 confirmOps 对象(与 hiddenTabs 整组写入同款),
220
+ * 乐观值顶住回显、失败回滚 + toast。总开关关闭时明细行禁用(不弹卡由总开关决定)。
221
+ */
222
+ const toggleConfirmOp = (op, next) => {
223
+ if (!writable || pendingOps !== undefined || !confirmWrites || confirmOps[op] === next)
224
+ return;
225
+ const merged = { ...confirmOps, [op]: next };
226
+ setPendingOps(merged);
227
+ const seq = ++writeSeqRef.current;
228
+ void scope.set('confirmOps', merged)
229
+ .then(() => {
230
+ setPendingOps(undefined);
231
+ verifyWritten(seq, 'confirmOps', merged);
232
+ })
233
+ .catch((err) => {
234
+ setPendingOps(undefined);
235
+ toastError(err);
236
+ });
237
+ };
198
238
  const commitLimit = () => {
199
239
  const raw = limitDraft;
200
240
  setLimitDraft(null);
201
- setLimitError(false);
241
+ setLimitError(undefined);
202
242
  if (raw === null || !writable)
203
243
  return;
204
244
  // 空串视为放弃编辑:静默回显保存值。若在此报错,会同时出现「已填回旧值」与
@@ -208,11 +248,12 @@ export function SettingsSection(props) {
208
248
  const parsed = parseMemoryLimit(raw);
209
249
  // 非法输入就地报错(不再静默回弹):用户明确知道哪里错、该怎么改
210
250
  if (parsed === undefined) {
211
- setLimitError(true);
251
+ setLimitError('invalid');
212
252
  return;
213
253
  }
214
254
  if (parsed.clamped) {
215
- // 越界但可夹取:按夹取值提交并回显,同时给出行内提示
255
+ // 越界但可夹取:按夹取值提交并回显,给说明性提示而非报错——值已成功保存,
256
+ // 「请输入合法整数」的报错口径与实际行为矛盾
216
257
  const seq = ++writeSeqRef.current;
217
258
  setPendingLimit(parsed.value);
218
259
  void scope.set('memoryInjectLimit', parsed.value)
@@ -222,7 +263,7 @@ export function SettingsSection(props) {
222
263
  verifyWritten(seq, 'memoryInjectLimit', parsed.value);
223
264
  })
224
265
  .catch((err) => { setPendingLimit(undefined); toastError(err); });
225
- setLimitError(true);
266
+ setLimitError('clamped');
226
267
  return;
227
268
  }
228
269
  if (parsed.value === snap.value?.memoryInjectLimit)
@@ -236,10 +277,6 @@ export function SettingsSection(props) {
236
277
  })
237
278
  .catch((err) => { setPendingLimit(undefined); toastError(err); });
238
279
  };
239
- const coachLabel = (id) => {
240
- const index = COACH_IDS.indexOf(id);
241
- return index >= 0 ? t(COACH_KEYS[index]) : (id ?? '');
242
- };
243
280
  const saveCoach = (style) => {
244
281
  if (saving || style === profile?.coachStyle)
245
282
  return;
@@ -265,16 +302,19 @@ export function SettingsSection(props) {
265
302
  flashSaved(t('settings.profile.saved'));
266
303
  }));
267
304
  };
268
- // 三个分节各自成面板卡(与全站卡片语言一致),字段不再裸堆叠;卡内节奏由样式层统一
305
+ // 分节各自成面板卡(与全站卡片语言一致);开关类设置用 row 语法(标签+说明在左、
306
+ // 控件在右),表单类字段保持 label 在上的纵排——两类各有惯例,不混用
269
307
  return createElement('div', { className: 'xy-settings' }, createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.coach.title')), createElement('div', { className: 'xy-seg', role: 'group', 'aria-label': t('settings.coach.title') }, ...COACH_IDS.map((id, i) => createElement('button', {
270
308
  key: id,
271
309
  className: `xy-seg-btn${profile?.coachStyle === id ? ' xy-on' : ''}`,
272
310
  'aria-pressed': profile?.coachStyle === id,
273
311
  disabled: saving,
274
312
  onClick: () => saveCoach(id),
275
- }, t(COACH_KEYS[i])))), createElement('p', { className: 'xy-hint' }, profileError !== undefined ? t('settings.coach.loadFailed', { error: profileError })
313
+ }, t(COACH_KEYS[i])))),
314
+ // 分段按钮已表达当前风格,说明行只讲作用不报状态;错误态保留重试入口
315
+ createElement('p', { className: 'xy-hint' }, profileError !== undefined ? t('settings.coach.loadFailed', { error: profileError })
276
316
  : profile === undefined ? t('common.loading') + '…'
277
- : t('settings.coach.current', { label: coachLabel(profile.coachStyle) })), profileError !== undefined
317
+ : t('settings.coach.hint')), profileError !== undefined
278
318
  ? createElement('button', { className: 'xy-btn', onClick: loadProfile }, t('common.retry'))
279
319
  : null),
280
320
  // 画像字段:label 包裹关联控件(可点击标签聚焦输入),小标题在上、示例 placeholder 在下,
@@ -295,11 +335,14 @@ export function SettingsSection(props) {
295
335
  className: 'xy-btn xy-btn-primary', disabled: saving, onClick: saveProfile,
296
336
  }, saving ? t('settings.profile.saving') : t('settings.profile.save')), savedMsg !== '' ? createElement('span', { className: 'xy-saved', role: 'status' },
297
337
  // 勾形装饰对读屏隐藏(语义由文案承担),与卡片完成态同一语法
298
- createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')),
299
- // 整页 section 没有官方卡片那套「按命名空间自动显隐」的保护,失败呈现归注册方
338
+ createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))),
339
+ // 整页 section 没有官方卡片那套「按命名空间自动显隐」的保护,失败呈现归注册方。
340
+ // 命名空间级通知作用于下方两卡(写操作确认/对话偏好),页级一次说清不逐卡重复
300
341
  prefNoticeKey !== undefined
301
342
  ? createElement('p', { className: 'xy-hint' }, t(prefNoticeKey))
302
- : null, createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, createElement('input', {
343
+ : null,
344
+ // 写操作确认独立成卡:安全类设置自成一组,与一般行为参数分离
345
+ createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.confirm.title')), createElement('label', { className: 'xy-setrow' }, createElement('span', { className: 'xy-setrow-main' }, createElement('span', { className: 'xy-setrow-label' }, t('settings.confirm.master')), createElement('span', { className: 'xy-setrow-desc' }, t('settings.pref.confirmWritesHint'))), createElement('input', {
303
346
  type: 'checkbox',
304
347
  className: 'xy-toggle',
305
348
  name: 'confirmWrites',
@@ -319,35 +362,57 @@ export function SettingsSection(props) {
319
362
  toastError(err);
320
363
  });
321
364
  },
322
- }), t('settings.pref.confirmWrites')), createElement('p', { className: 'xy-hint' }, t('settings.pref.confirmWritesHint'))), createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, t('settings.pref.memoryLimit')), createElement('input', {
365
+ })),
366
+ // 确认类目明细(锁定删除行恒展示):总开关关闭时整组禁用置灰——不弹卡由总开关
367
+ // 决定;禁用原因由组尾 hint 承担(aria-describedby 关联),不在每行重复
368
+ createElement('div', {
369
+ className: `xy-confirm-ops${confirmWrites ? '' : ' xy-confirm-ops-off'}`,
370
+ role: 'group',
371
+ 'aria-labelledby': 'xy-confirm-ops-head',
372
+ 'aria-describedby': 'xy-confirm-ops-hint',
373
+ }, createElement('span', { className: 'xy-op-group-head', id: 'xy-confirm-ops-head' }, t('settings.pref.confirmOps.group')), createElement('div', { className: 'xy-setrows' }, ...CONFIRM_OPS.map((op) => createElement('label', { className: 'xy-setrow', key: op }, createElement('span', { className: 'xy-setrow-main' }, createElement('span', { className: 'xy-setrow-label' }, t(CONFIRM_OP_LABEL_KEYS[op]))), createElement('input', {
374
+ type: 'checkbox',
375
+ className: 'xy-toggle',
376
+ name: `confirmOps.${op}`,
377
+ checked: confirmOps[op],
378
+ disabled: !writable || pendingOps !== undefined || !confirmWrites,
379
+ onChange: (e) => toggleConfirmOp(op, e.target.checked),
380
+ }))),
381
+ // 锁定类目:div + 「始终开启」徽章(不可交互,不用禁用开关表达恒开语义)
382
+ createElement('div', { className: 'xy-setrow' }, createElement('span', { className: 'xy-setrow-main' }, createElement('span', { className: 'xy-setrow-label' }, t('settings.pref.confirmOps.delete')), createElement('span', { className: 'xy-setrow-desc' }, t('settings.pref.confirmOps.deleteHint'))), createElement('span', { className: 'xy-locked' }, t('settings.pref.confirmOps.locked'))))), createElement('span', { className: 'xy-hint', id: 'xy-confirm-ops-hint' }, t('settings.pref.confirmOps.hint'))),
383
+ // 一般对话行为参数:记忆注入上限 + 确认卡语言
384
+ createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')), createElement('div', { className: 'xy-setrows' }, createElement('label', { className: 'xy-setrow' }, createElement('span', { className: 'xy-setrow-main' }, createElement('span', { className: 'xy-setrow-label' }, t('settings.pref.memoryLimit')), createElement('span', { className: 'xy-setrow-desc' }, t('settings.pref.memoryLimitHint')), limitError === 'invalid'
385
+ ? createElement('span', { id: 'xy-limit-error', className: 'xy-field-err', role: 'alert' }, t('settings.pref.limitInvalid'))
386
+ : limitError === 'clamped'
387
+ ? createElement('span', { id: 'xy-limit-error', className: 'xy-hint', role: 'status' }, t('settings.pref.limitClamped'))
388
+ : null), createElement('input', {
323
389
  type: 'number', min: MEMORY_LIMIT_MIN, max: MEMORY_LIMIT_MAX,
324
390
  className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
391
+ autoComplete: 'off',
325
392
  'aria-label': t('settings.pref.memoryLimit'),
326
- 'aria-invalid': limitError || undefined,
393
+ 'aria-invalid': limitError === 'invalid' || undefined,
327
394
  ...(limitError ? { 'aria-describedby': 'xy-limit-error' } : {}),
328
395
  // 写入在途时顶住乐观值并禁用(与开关同款),避免闪回旧值与并发写交错
329
396
  value: limitDraft ?? (pendingLimit !== undefined ? String(pendingLimit) : limit),
330
397
  disabled: !writable || pendingLimit !== undefined,
331
- onChange: (e) => { setLimitDraft(e.target.value); setLimitError(false); },
398
+ onChange: (e) => { setLimitDraft(e.target.value); setLimitError(undefined); },
332
399
  onBlur: commitLimit,
333
400
  onKeyDown: (e) => {
334
401
  if (e.key === 'Enter')
335
402
  e.currentTarget.blur();
336
403
  },
337
- }), createElement('span', { className: 'xy-hint' }, t('settings.pref.memoryLimitHint')), limitError
338
- ? createElement('span', { id: 'xy-limit-error', className: 'xy-field-err', role: 'alert' }, t('settings.pref.limitInvalid'))
339
- : null),
404
+ })),
340
405
  // 确认卡语言:平台不向 host 侧暴露界面语言(rc.2 实测),对话侧确认卡文案无法
341
- // 自动跟随界面语言——由此处显式选择,即时热生效(hitl/tools 每次执行读 thunk)
342
- createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, t('settings.pref.confirmLang')), createElement('select', {
343
- className: 'xy-input', value: confirmLang, name: 'confirmLang',
344
- 'aria-label': t('settings.pref.confirmLang'),
406
+ // 自动跟随界面语言——由此处显式选择,即时热生效(hitl/tools 每次执行读 thunk)。
407
+ // 两档枚举与教练风格共用分段控件(下拉对小枚举过重);行内是按钮组故不用 label
408
+ createElement('div', { className: 'xy-setrow' }, createElement('span', { className: 'xy-setrow-main' }, createElement('span', { className: 'xy-setrow-label' }, t('settings.pref.confirmLang')), createElement('span', { className: 'xy-setrow-desc' }, t('settings.pref.confirmLangHint'))), createElement('span', { className: 'xy-seg', role: 'group', 'aria-label': t('settings.pref.confirmLang') }, ...['zh', 'en'].map((lang) => createElement('button', {
409
+ key: lang,
410
+ type: 'button',
411
+ className: `xy-seg-btn${confirmLang === lang ? ' xy-on' : ''}`,
412
+ 'aria-pressed': confirmLang === lang,
345
413
  disabled: !writable || pendingLang !== undefined,
346
- onChange: (e) => {
347
- if (e.target.value === 'zh' || e.target.value === 'en')
348
- switchConfirmLang(e.target.value);
349
- },
350
- }, createElement('option', { value: 'zh' }, t('settings.pref.confirmLang.zh')), createElement('option', { value: 'en' }, t('settings.pref.confirmLang.en'))), createElement('span', { className: 'xy-hint' }, t('settings.pref.confirmLangHint')))),
414
+ onClick: () => switchConfirmLang(lang),
415
+ }, t(lang === 'zh' ? 'settings.pref.confirmLang.zh' : 'settings.pref.confirmLang.en'))))))),
351
416
  // 标签页显示:模式三态(跟随会话/始终显示/始终隐藏)+ 六个标签勾选 chips。
352
417
  // 与教练风格卡同一 xy-seg 视觉语法;「始终隐藏」时勾选区整组禁用置灰。
353
418
  // 命名空间常驻于 bundle 层(未选星愿预设也可调),见 src/ui-settings.ts 头注。
@@ -365,12 +430,14 @@ export function SettingsSection(props) {
365
430
  'aria-label': t('settings.tabs.chooseTab'),
366
431
  }, ...TAB_IDS.map((id) => {
367
432
  const shown = !hiddenTabs.includes(id);
433
+ // 多选勾选态加勾形装饰(对读屏隐藏,状态由 aria-pressed 承担):
434
+ // 与上方单选的模式分段在视觉上区分「可多选」
368
435
  return createElement('button', {
369
436
  key: id,
370
437
  className: `xy-seg-btn${shown ? ' xy-on' : ''}`,
371
438
  'aria-pressed': shown,
372
439
  disabled: !uiWritable || pendingHidden !== undefined || tabMode === 'hide',
373
440
  onClick: () => toggleTab(id, !shown),
374
- }, t(TAB_LABEL_KEYS[id]));
441
+ }, shown ? createElement('span', { 'aria-hidden': 'true' }, '✓ ') : null, t(TAB_LABEL_KEYS[id]));
375
442
  })), createElement('p', { className: 'xy-hint' }, t('settings.tabs.hint'))), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
376
443
  }
@@ -60,6 +60,9 @@ export const STYLE_TEXT = `
60
60
  --xyd-on-c2:#7c4a03;
61
61
  --xyd-on-c3:#065f46;
62
62
  --xyd-on-dcell:#0b3d26;
63
+ /* warn-soft 底的前景伙伴(StaleBanner 文字):壳通用次要色对琥珀柔底浅 4.19 /
64
+ 深 2.95,跌破 WCAG 1.4.3 AA——按 ≥4.5:1 校准的专用对(on-c2 同族暖琥珀前景) */
65
+ --xyd-on-warn:#7c4a03;
63
66
  /* 阴影对(卡片悬停/toast/弹窗/英雄卡):深浅各自配档,禁止正文区裸写 rgba */
64
67
  --xyd-shadow-card:0 2px 10px rgba(15,23,42,.07);
65
68
  --xyd-shadow-toast:0 8px 24px -8px rgba(15,23,42,.2),0 2px 8px -2px rgba(15,23,42,.1);
@@ -109,6 +112,7 @@ body[data-ds-dark-theme]{
109
112
  --xyd-on-c2:#ffd9a8;
110
113
  --xyd-on-c3:#a7f3c9;
111
114
  --xyd-on-dcell:#a7f3c9;
115
+ --xyd-on-warn:#ffd9a8;
112
116
  --xyd-shadow-card:0 2px 10px rgba(0,0,0,.32);
113
117
  --xyd-shadow-toast:0 8px 24px -8px rgba(0,0,0,.5),0 2px 8px -2px rgba(0,0,0,.35);
114
118
  --xyd-shadow-modal:0 24px 48px -16px rgba(0,0,0,.55),0 4px 12px -4px rgba(0,0,0,.4);
@@ -260,7 +264,7 @@ body[data-ds-dark-theme] .xy-badge-cat{background:hsl(var(--cat-h,275) calc(var(
260
264
  .xy-input:disabled{opacity:.55;cursor:not-allowed}
261
265
  .xy-field-err{color:var(--xyd-danger);font-size:12px;margin-top:4px}
262
266
  /* 数字微输入(设置页注入上限等):按内容长度定宽,不随浏览器默认 size 拉成宽条 */
263
- .xy-input-num{width:96px}
267
+ .xy-input-num{width:96px;font-variant-numeric:tabular-nums}
264
268
 
265
269
  /* 列表行 */
266
270
  .xy-rowmain{display:flex;flex-direction:column;gap:2px;min-width:0}
@@ -276,6 +280,9 @@ body[data-ds-dark-theme] .xy-badge-cat{background:hsl(var(--cat-h,275) calc(var(
276
280
  .xy-banner-ok{margin-top:10px;padding:10px 12px;border-radius:var(--xyd-r-inner);background:var(--xyd-ok-soft);border:1px solid var(--xyd-ok-border);font-size:13px}
277
281
  /* 刷新失败但旧数据仍在的诚实降级横幅:琥珀虚线面板(警示但不恐吓——动作已成功) */
278
282
  .xy-stalerow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding:8px 12px;border:1px dashed var(--xyd-warn-border);border-radius:var(--xyd-r-inner);background:var(--xyd-warn-soft)}
283
+ /* 提示文字用琥珀柔底的专用前景对(--xyd-on-warn):次要通用色在此底上浅 4.19 / 深
284
+ * 2.95,深色远低于 AA;重试键保持 label-primary(对琥珀柔底 >10:1),无需处理 */
285
+ .xy-stalerow .xy-meta{color:var(--xyd-on-warn)}
279
286
  .xy-stalerow .xy-btn-inline{margin-left:0}
280
287
 
281
288
  /* ===== 分组卡(任务页状态分桶 / 今日页待打卡·已完成共用)=====
@@ -499,25 +506,54 @@ button.xy-growth-col.xy-hover{box-shadow:inset 0 0 0 2px var(--xyd-accent-ring)}
499
506
 
500
507
  /* ===== 设置页 ===== */
501
508
  .xy-settings{display:flex;flex-direction:column;gap:16px;max-width:560px;color:var(--dsw-alias-label-primary);font-size:14px}
502
- /* 设置三个分节各自成面板卡;间距交给 xy-settings 的 gap,卡内子元素统一 10px 纵向节奏 */
509
+ /* 游离于卡片外的说明行(页级命名空间通知/页尾数据说明)随卡内文字对齐
510
+ * (xy-panel 横向内边距 14px),不得贴容器左缘悬在列外 */
511
+ .xy-settings>.xy-hint{padding:0 14px}
512
+ /* 设置各分节自成面板卡;间距交给 xy-settings 的 gap,卡内子元素统一 10px 纵向节奏 */
503
513
  .xy-settings .xy-panel{margin-top:0}
504
514
  .xy-settings .xy-panel>*+*{margin-top:10px}
505
515
  .xy-field{display:flex;flex-direction:column;gap:6px}
506
- .xy-field-head{display:flex;align-items:center;gap:8px;font-weight:600}
516
+ /* 设置行(业界设置页 row 惯例):标签+说明在左、控件在右,扫描线对齐;说明文字
517
+ * flex:1 收缩换行、控件 flex:none 不被挤压,窄面板下控件换行左对齐。
518
+ * label 行整行可点(label 包裹控件);div 行(分段按钮组/锁定行)不可点。 */
519
+ .xy-setrow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
520
+ label.xy-setrow{cursor:pointer}
521
+ .xy-setrow-main{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 220px}
522
+ .xy-setrow-label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}
523
+ .xy-setrow-desc{color:var(--dsw-alias-label-secondary);font-size:12px;margin:0;overflow-wrap:anywhere}
524
+ /* 行列表:行间 hairline 分隔(与分组卡 xy-grouprow 同一语法),首行不画线 */
525
+ .xy-setrows{display:flex;flex-direction:column}
526
+ .xy-setrows>.xy-setrow{padding:8px 0}
527
+ .xy-setrows>.xy-setrow+.xy-setrow{border-top:1px solid var(--dsw-alias-border-l1)}
528
+ .xy-setrow>.xy-toggle,.xy-setrow>.xy-input-num,.xy-setrow>.xy-seg{flex:none}
507
529
  .xy-input-wide{width:100%;max-width:360px;box-sizing:border-box}
508
530
  .xy-seg{display:flex;gap:6px;flex-wrap:wrap}
509
531
  .xy-seg-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:999px;padding:5px 14px;font-size:13px;cursor:pointer;transition:border-color .15s ease,background-color .15s ease,color .15s ease}
532
+ .xy-seg-btn:disabled{opacity:.55;cursor:not-allowed}
510
533
  @media (hover:hover) and (pointer:fine){
511
- .xy-seg-btn:hover{border-color:var(--xyd-accent)}
534
+ .xy-seg-btn:hover:not(:disabled){border-color:var(--xyd-accent)}
512
535
  }
513
536
  /* 选中态双选择器:类名与 aria-pressed 属性等价(组件侧用属性表达状态语义) */
514
537
  .xy-seg-btn.xy-on,.xy-seg-btn[aria-pressed='true']{background:var(--xyd-accent);border-color:transparent;color:var(--xyd-on-accent)}
515
538
  .xy-save-row{display:flex;align-items:center;gap:10px;margin-top:2px}
516
539
  .xy-toggle{width:38px;height:22px;appearance:none;border-radius:999px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);position:relative;cursor:pointer;transition:background .15s}
540
+ /* 禁用即置灰(与 xy-btn/xy-input 同语法):只读命名空间/写入在途的控件不得看起来仍可交互 */
541
+ .xy-toggle:disabled{opacity:.4;cursor:not-allowed}
517
542
  .xy-toggle:checked{background:var(--xyd-accent);border-color:transparent}
518
543
  .xy-toggle::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary);transition:transform .15s}
519
544
  .xy-toggle:checked::after{transform:translateX(16px);background:var(--xyd-on-accent)}
520
545
  .xy-hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin:0}
546
+ /* 确认类目明细:总开关行下平铺(row 语法 + 组头),不再缩进嵌套;
547
+ * 总开关关闭时整组禁用弱化必须显式写(自定义 toggle 不吃原生禁用变灰),
548
+ * 行文字同步降权;禁用原因由组尾 hint 承担(aria-describedby 关联) */
549
+ .xy-confirm-ops{display:flex;flex-direction:column;gap:4px}
550
+ /* 关闭态只降权可切换的类目行(label 行):锁定删除行不受总开关支配(始终确认),
551
+ * 置灰会让「始终开启」徽章与灰字自相矛盾 */
552
+ .xy-confirm-ops-off label.xy-setrow .xy-setrow-label{color:var(--dsw-alias-label-secondary)}
553
+ .xy-op-group-head{font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-secondary)}
554
+ /* 锁定类目(删除恒确认):「始终开启」徽章表达冻结语义——禁用开关看着像坏了,
555
+ * 徽章即状态;胶囊 999 符合形状纪律 */
556
+ .xy-locked{flex:none;font-size:12px;line-height:1.4;padding:3px 9px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--xyd-label-on-2)}
521
557
 
522
558
  /* ===== 详情聚合视图 ===== */
523
559
  /* 段间 10px 呼吸;每段内部「标签 → 内容」固定 5px 纵向节奏(label 不再与内容文字挤行)。