@dickpy/dsh-imagegen 1.2.3 → 1.4.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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -181
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +2711 -1318
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +830 -155
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -316
  10. package/src/client/ImageGenPanel.tsx +1703 -1476
  11. package/src/client/SettingsCard.tsx +936 -648
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -0
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +170 -152
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -484
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -536
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -250
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -464
  31. package/src/gallery-store.ts +286 -280
  32. package/src/generation-runtime.ts +79 -48
  33. package/src/history-store.ts +250 -238
  34. package/src/image-format.ts +11 -0
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -212
  37. package/src/model-catalog.ts +115 -0
  38. package/src/presets.ts +71 -0
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -253
  41. package/src/routes.ts +916 -738
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
@@ -1,336 +1,336 @@
1
- /**
2
- * Staged form model behind the plugin settings card. A card stages what the
3
- * user types and writes it only when they save — the settings write is a
4
- * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Self-contained slice of the same
6
- * pattern the dsh-web-ui family cards use (this package must not depend on a
7
- * sibling UI package).
8
- */
9
-
10
- import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
11
-
12
- /** The write one field's staged text performs when the card is saved. */
13
- export type FieldWrite =
14
- | { kind: 'set'; value: unknown }
15
- | { kind: 'clear' }
16
-
17
- /** How one field converts between its stored value and its draft text. */
18
- export interface FieldSpec {
19
- /** Field name inside the namespace section. */
20
- field: string
21
- /** Render a stored value as draft text; the empty string when the section carries none. */
22
- format: (value: unknown) => string
23
- /**
24
- * The write this draft text stages, or undefined when the text is not a
25
- * value this field accepts — which blocks the save rather than discarding it.
26
- */
27
- parse: (text: string) => FieldWrite | undefined
28
- /**
29
- * True for secret fields (role('secret') in the namespace schema): the
30
- * redacted wire view never returns the stored value, so the form treats an
31
- * empty draft as "no change" and judges writes by the namespace's secrets
32
- * sidecar instead of the user layer.
33
- */
34
- secret?: boolean
35
- }
36
-
37
- /** One field as the card renders it. */
38
- export interface FieldState {
39
- /** Draft text the control renders. */
40
- text: string
41
- /** Whether saving would leave a user-layer entry for this field. */
42
- overridden: boolean
43
- /** Whether the draft is not a value this field accepts, which blocks saving. */
44
- invalid: boolean
45
- }
46
-
47
- /** Form state every plugin settings card shares. */
48
- export interface CardShell {
49
- /** False while the namespace is still loading; the card renders nothing. */
50
- available: boolean
51
- /** Whether the namespace is actually served (the bridge answered). */
52
- exposed: boolean
53
- /** Whether the Host document accepts writes. */
54
- writable: boolean
55
- /** Whether the form holds edits that a save would write. */
56
- dirty: boolean
57
- /** Whether any staged draft is invalid, which blocks the save. */
58
- invalid: boolean
59
- /** Whether a save is crossing the wire. */
60
- saving: boolean
61
- /** Whether the last save did not land as staged; cleared by the next edit or save. */
62
- failed: boolean
63
- }
64
-
65
- /** The write actions the card's slot entry injects. */
66
- export interface CardActions {
67
- /** Stage draft text for one field. */
68
- edit: (field: string, text: string) => void
69
- /** Stage a clear, so saving lets the field re-inherit the composition layer. */
70
- resetField: (field: string) => void
71
- /** Write every staged edit, then re-seed from what the Host accepted. */
72
- save: () => void
73
- /** Drop every staged edit. */
74
- discard: () => void
75
- }
76
-
77
- /** One field's staged edit. */
78
- interface StagedEdit {
79
- /** Draft text the control renders. */
80
- text: string
81
- /** True when this edit clears the field whatever text it shows. */
82
- clear: boolean
83
- }
84
-
85
- /** One staged edit resolved into the write a save performs. */
86
- interface PlannedWrite {
87
- /** Field this entry writes. */
88
- field: string
89
- /** Perform the write and report whether the Host holds the staged value afterwards. */
90
- run: (() => Promise<boolean>) | undefined
91
- }
92
-
93
- /** A free-text field. An empty draft clears the field. */
94
- export function textField(field: string): FieldSpec {
95
- return {
96
- field,
97
- format: value => typeof value === 'string' ? value : '',
98
- parse: (text) => {
99
- const trimmed = text.trim()
100
- return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
101
- },
102
- }
103
- }
104
-
105
- /** A newline/comma-separated model list, persisted as a normalized string array. */
106
- export function stringListField(field: string): FieldSpec {
107
- return {
108
- field,
109
- format: value => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').join('\n') : '',
110
- parse: (text) => {
111
- const values = [...new Set(text.split(/[\n,]/).map(item => item.trim()).filter(Boolean))]
112
- return values.length === 0 ? { kind: 'clear' } : { kind: 'set', value: values }
113
- },
114
- }
115
- }
116
-
117
- /** A boolean field, edited through true/false draft text. */
118
- export function booleanField(field: string): FieldSpec {
119
- return {
120
- field,
121
- format: value => typeof value === 'boolean' ? String(value) : '',
122
- parse: (text) => {
123
- if (text === 'true') return { kind: 'set', value: true }
124
- if (text === 'false') return { kind: 'set', value: false }
125
- return undefined
126
- },
127
- }
128
- }
129
-
130
- /**
131
- * A secret field (role('secret') in the namespace schema). The stored value is
132
- * never rendered or returned by the redacted wire view, so:
133
- * - an empty draft means "no change" (typing nothing must never clear an
134
- * invisible stored key); the dedicated clear action stages an explicit clear;
135
- * - a write's outcome is judged by the namespace's secrets sidecar through
136
- * the {@link CardForm} `secretSettled` hook, never by the user layer.
137
- */
138
- export function secretField(field: string): FieldSpec {
139
- return {
140
- field,
141
- secret: true,
142
- format: () => '',
143
- parse: (text) => {
144
- const trimmed = text.trim()
145
- if (trimmed === '') return undefined
146
- return { kind: 'set', value: trimmed }
147
- },
148
- }
149
- }
150
-
151
- /**
152
- * Stages one card's edits over one settings scope and writes them on save.
153
- *
154
- * The Host is the only authority on whether a value was accepted — its
155
- * validators own the constraints no schema can express — so the outcome is
156
- * read back from the section rather than predicted here. A save that did not
157
- * land keeps its drafts, so the user can correct them instead of retyping.
158
- */
159
- export class CardForm<T> {
160
- private readonly specs: Map<string, FieldSpec>
161
- private readonly staged = new Map<string, StagedEdit>()
162
- private readonly listeners = new Set<() => void>()
163
- private saving = false
164
- private failed = false
165
-
166
- /**
167
- * @param scope - the bound settings scope for this card's namespace.
168
- * @param specs - the fields this card edits.
169
- * @param options.secretSettled - for secret fields, whether the namespace
170
- * currently holds a stored secret (the redacted view never round-trips the
171
- * value, so a write's outcome is read from the secrets sidecar instead).
172
- */
173
- constructor(
174
- private readonly scope: SettingsScope<T>,
175
- specs: FieldSpec[],
176
- private readonly options: { secretSettled?: (field: string) => boolean } = {},
177
- ) {
178
- this.specs = new Map(specs.map(spec => [spec.field, spec]))
179
- scope.subscribe(() => { this.publish() })
180
- }
181
-
182
- /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
183
- bind<S>(project: () => S): SnapshotStore<S> {
184
- const store = createSnapshotStore(project())
185
- this.listeners.add(() => { store.set(project()) })
186
- return store
187
- }
188
-
189
- /** Read the card-level state: what the Host serves, and what a save would do. */
190
- shell(): CardShell {
191
- const snapshot = this.scope.getSnapshot()
192
- const plan = this.plan()
193
- return {
194
- available: snapshot.status !== 'loading',
195
- exposed: snapshot.status === 'ready',
196
- writable: snapshot.writable,
197
- dirty: plan.length > 0,
198
- invalid: plan.some(item => item.run === undefined),
199
- saving: this.saving,
200
- failed: this.failed,
201
- }
202
- }
203
-
204
- /** Read one field's state from the effective section and its staged draft. */
205
- field(field: string): FieldState {
206
- const spec = this.specOf(field)
207
- const staged = this.staged.get(field)
208
- if (staged === undefined) {
209
- return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
210
- }
211
- const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
212
- return {
213
- text: staged.text,
214
- overridden: write?.kind === 'set',
215
- // A secret field's empty draft is "no change" (see secretField), never
216
- // an invalid state — the stored value is invisible, so the user cannot
217
- // be expected to type over it.
218
- invalid: write === undefined && !(spec.secret === true && staged.text.trim() === ''),
219
- }
220
- }
221
-
222
- /** The actions the card's slot registration injects. */
223
- actions(): CardActions {
224
- return {
225
- edit: (field, text) => { this.stage(field, { text, clear: false }) },
226
- resetField: (field) => {
227
- this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
228
- },
229
- save: () => { void this.save() },
230
- discard: () => {
231
- if (this.staged.size === 0 && !this.failed) return
232
- this.staged.clear()
233
- this.failed = false
234
- this.publish()
235
- },
236
- }
237
- }
238
-
239
- /**
240
- * Write every staged edit, then re-seed from what the Host accepted.
241
- * @returns settlement after every write and the read-back.
242
- */
243
- async save(): Promise<void> {
244
- const plan = this.plan()
245
- const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
246
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return
247
- this.saving = true
248
- this.failed = false
249
- this.publish()
250
- let landed = true
251
- for (const write of writes) {
252
- landed = await write() && landed
253
- }
254
- if (landed) this.staged.clear()
255
- this.saving = false
256
- this.failed = !landed
257
- this.publish()
258
- }
259
-
260
- /**
261
- * Every staged edit a save would write. An entry whose draft is not a value
262
- * its field accepts carries no write: the form is still dirty, and the save
263
- * refuses rather than dropping the edit. A staged edit that matches the
264
- * effective section is not a write at all.
265
- */
266
- private plan(): PlannedWrite[] {
267
- const plan: PlannedWrite[] = []
268
- for (const [field, staged] of this.staged) {
269
- const spec = this.specOf(field)
270
- if (staged.clear) {
271
- const present = spec.secret === true
272
- ? (this.options.secretSettled?.(field) ?? false)
273
- : this.stored(field)
274
- if (present) plan.push({ field, run: () => this.clear(field) })
275
- continue
276
- }
277
- if (staged.text === spec.format(this.sectionValue(field))) continue
278
- const write = spec.parse(staged.text)
279
- if (write === undefined) plan.push({ field, run: undefined })
280
- else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
281
- else plan.push({ field, run: () => this.store(field, write.value) })
282
- }
283
- return plan
284
- }
285
-
286
- private async clear(field: string): Promise<boolean> {
287
- await this.scope.unset(field)
288
- const spec = this.specOf(field)
289
- if (spec.secret === true) return !(this.options.secretSettled?.(field) ?? false)
290
- return !this.stored(field)
291
- }
292
-
293
- private async store(field: string, value: unknown): Promise<boolean> {
294
- await this.scope.set(field, value)
295
- const spec = this.specOf(field)
296
- if (spec.secret === true) return this.options.secretSettled?.(field) ?? true
297
- return this.userLayer()?.[field] === value
298
- }
299
-
300
- private stage(field: string, edit: StagedEdit): void {
301
- this.staged.set(field, edit)
302
- this.failed = false
303
- this.publish()
304
- }
305
-
306
- private specOf(field: string): FieldSpec {
307
- const spec = this.specs.get(field)
308
- if (spec === undefined) throw new Error(`settings card has no field ${field}`)
309
- return spec
310
- }
311
-
312
- private snapshotOf(): SettingsScopeSnapshot<T> {
313
- return this.scope.getSnapshot()
314
- }
315
-
316
- private sectionValue(field: string): unknown {
317
- return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
318
- }
319
-
320
- private baseValue(field: string): unknown {
321
- return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
322
- }
323
-
324
- private userLayer(): Record<string, unknown> | undefined {
325
- return this.snapshotOf().user as Record<string, unknown> | undefined
326
- }
327
-
328
- private stored(field: string): boolean {
329
- const user = this.userLayer()
330
- return user !== undefined && Object.hasOwn(user, field)
331
- }
332
-
333
- private publish(): void {
334
- for (const listener of [...this.listeners]) listener()
335
- }
336
- }
1
+ /**
2
+ * Staged form model behind the plugin settings card. A card stages what the
3
+ * user types and writes it only when they save — the settings write is a
4
+ * durable, revision-fenced document mutation, so staging keeps what is on
5
+ * screen exactly what a save would store. Self-contained slice of the same
6
+ * pattern the dsh-web-ui family cards use (this package must not depend on a
7
+ * sibling UI package).
8
+ */
9
+
10
+ import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
11
+
12
+ /** The write one field's staged text performs when the card is saved. */
13
+ export type FieldWrite =
14
+ | { kind: 'set'; value: unknown }
15
+ | { kind: 'clear' }
16
+
17
+ /** How one field converts between its stored value and its draft text. */
18
+ export interface FieldSpec {
19
+ /** Field name inside the namespace section. */
20
+ field: string
21
+ /** Render a stored value as draft text; the empty string when the section carries none. */
22
+ format: (value: unknown) => string
23
+ /**
24
+ * The write this draft text stages, or undefined when the text is not a
25
+ * value this field accepts — which blocks the save rather than discarding it.
26
+ */
27
+ parse: (text: string) => FieldWrite | undefined
28
+ /**
29
+ * True for secret fields (role('secret') in the namespace schema): the
30
+ * redacted wire view never returns the stored value, so the form treats an
31
+ * empty draft as "no change" and judges writes by the namespace's secrets
32
+ * sidecar instead of the user layer.
33
+ */
34
+ secret?: boolean
35
+ }
36
+
37
+ /** One field as the card renders it. */
38
+ export interface FieldState {
39
+ /** Draft text the control renders. */
40
+ text: string
41
+ /** Whether saving would leave a user-layer entry for this field. */
42
+ overridden: boolean
43
+ /** Whether the draft is not a value this field accepts, which blocks saving. */
44
+ invalid: boolean
45
+ }
46
+
47
+ /** Form state every plugin settings card shares. */
48
+ export interface CardShell {
49
+ /** False while the namespace is still loading; the card renders nothing. */
50
+ available: boolean
51
+ /** Whether the namespace is actually served (the bridge answered). */
52
+ exposed: boolean
53
+ /** Whether the Host document accepts writes. */
54
+ writable: boolean
55
+ /** Whether the form holds edits that a save would write. */
56
+ dirty: boolean
57
+ /** Whether any staged draft is invalid, which blocks the save. */
58
+ invalid: boolean
59
+ /** Whether a save is crossing the wire. */
60
+ saving: boolean
61
+ /** Whether the last save did not land as staged; cleared by the next edit or save. */
62
+ failed: boolean
63
+ }
64
+
65
+ /** The write actions the card's slot entry injects. */
66
+ export interface CardActions {
67
+ /** Stage draft text for one field. */
68
+ edit: (field: string, text: string) => void
69
+ /** Stage a clear, so saving lets the field re-inherit the composition layer. */
70
+ resetField: (field: string) => void
71
+ /** Write every staged edit, then re-seed from what the Host accepted. */
72
+ save: () => void
73
+ /** Drop every staged edit. */
74
+ discard: () => void
75
+ }
76
+
77
+ /** One field's staged edit. */
78
+ interface StagedEdit {
79
+ /** Draft text the control renders. */
80
+ text: string
81
+ /** True when this edit clears the field whatever text it shows. */
82
+ clear: boolean
83
+ }
84
+
85
+ /** One staged edit resolved into the write a save performs. */
86
+ interface PlannedWrite {
87
+ /** Field this entry writes. */
88
+ field: string
89
+ /** Perform the write and report whether the Host holds the staged value afterwards. */
90
+ run: (() => Promise<boolean>) | undefined
91
+ }
92
+
93
+ /** A free-text field. An empty draft clears the field. */
94
+ export function textField(field: string): FieldSpec {
95
+ return {
96
+ field,
97
+ format: value => typeof value === 'string' ? value : '',
98
+ parse: (text) => {
99
+ const trimmed = text.trim()
100
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
101
+ },
102
+ }
103
+ }
104
+
105
+ /** A newline/comma-separated model list, persisted as a normalized string array. */
106
+ export function stringListField(field: string): FieldSpec {
107
+ return {
108
+ field,
109
+ format: value => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').join('\n') : '',
110
+ parse: (text) => {
111
+ const values = [...new Set(text.split(/[\n,]/).map(item => item.trim()).filter(Boolean))]
112
+ return values.length === 0 ? { kind: 'clear' } : { kind: 'set', value: values }
113
+ },
114
+ }
115
+ }
116
+
117
+ /** A boolean field, edited through true/false draft text. */
118
+ export function booleanField(field: string): FieldSpec {
119
+ return {
120
+ field,
121
+ format: value => typeof value === 'boolean' ? String(value) : '',
122
+ parse: (text) => {
123
+ if (text === 'true') return { kind: 'set', value: true }
124
+ if (text === 'false') return { kind: 'set', value: false }
125
+ return undefined
126
+ },
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A secret field (role('secret') in the namespace schema). The stored value is
132
+ * never rendered or returned by the redacted wire view, so:
133
+ * - an empty draft means "no change" (typing nothing must never clear an
134
+ * invisible stored key); the dedicated clear action stages an explicit clear;
135
+ * - a write's outcome is judged by the namespace's secrets sidecar through
136
+ * the {@link CardForm} `secretSettled` hook, never by the user layer.
137
+ */
138
+ export function secretField(field: string): FieldSpec {
139
+ return {
140
+ field,
141
+ secret: true,
142
+ format: () => '',
143
+ parse: (text) => {
144
+ const trimmed = text.trim()
145
+ if (trimmed === '') return undefined
146
+ return { kind: 'set', value: trimmed }
147
+ },
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Stages one card's edits over one settings scope and writes them on save.
153
+ *
154
+ * The Host is the only authority on whether a value was accepted — its
155
+ * validators own the constraints no schema can express — so the outcome is
156
+ * read back from the section rather than predicted here. A save that did not
157
+ * land keeps its drafts, so the user can correct them instead of retyping.
158
+ */
159
+ export class CardForm<T> {
160
+ private readonly specs: Map<string, FieldSpec>
161
+ private readonly staged = new Map<string, StagedEdit>()
162
+ private readonly listeners = new Set<() => void>()
163
+ private saving = false
164
+ private failed = false
165
+
166
+ /**
167
+ * @param scope - the bound settings scope for this card's namespace.
168
+ * @param specs - the fields this card edits.
169
+ * @param options.secretSettled - for secret fields, whether the namespace
170
+ * currently holds a stored secret (the redacted view never round-trips the
171
+ * value, so a write's outcome is read from the secrets sidecar instead).
172
+ */
173
+ constructor(
174
+ private readonly scope: SettingsScope<T>,
175
+ specs: FieldSpec[],
176
+ private readonly options: { secretSettled?: (field: string) => boolean } = {},
177
+ ) {
178
+ this.specs = new Map(specs.map(spec => [spec.field, spec]))
179
+ scope.subscribe(() => { this.publish() })
180
+ }
181
+
182
+ /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
183
+ bind<S>(project: () => S): SnapshotStore<S> {
184
+ const store = createSnapshotStore(project())
185
+ this.listeners.add(() => { store.set(project()) })
186
+ return store
187
+ }
188
+
189
+ /** Read the card-level state: what the Host serves, and what a save would do. */
190
+ shell(): CardShell {
191
+ const snapshot = this.scope.getSnapshot()
192
+ const plan = this.plan()
193
+ return {
194
+ available: snapshot.status !== 'loading',
195
+ exposed: snapshot.status === 'ready',
196
+ writable: snapshot.writable,
197
+ dirty: plan.length > 0,
198
+ invalid: plan.some(item => item.run === undefined),
199
+ saving: this.saving,
200
+ failed: this.failed,
201
+ }
202
+ }
203
+
204
+ /** Read one field's state from the effective section and its staged draft. */
205
+ field(field: string): FieldState {
206
+ const spec = this.specOf(field)
207
+ const staged = this.staged.get(field)
208
+ if (staged === undefined) {
209
+ return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
210
+ }
211
+ const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
212
+ return {
213
+ text: staged.text,
214
+ overridden: write?.kind === 'set',
215
+ // A secret field's empty draft is "no change" (see secretField), never
216
+ // an invalid state — the stored value is invisible, so the user cannot
217
+ // be expected to type over it.
218
+ invalid: write === undefined && !(spec.secret === true && staged.text.trim() === ''),
219
+ }
220
+ }
221
+
222
+ /** The actions the card's slot registration injects. */
223
+ actions(): CardActions {
224
+ return {
225
+ edit: (field, text) => { this.stage(field, { text, clear: false }) },
226
+ resetField: (field) => {
227
+ this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
228
+ },
229
+ save: () => { void this.save() },
230
+ discard: () => {
231
+ if (this.staged.size === 0 && !this.failed) return
232
+ this.staged.clear()
233
+ this.failed = false
234
+ this.publish()
235
+ },
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Write every staged edit, then re-seed from what the Host accepted.
241
+ * @returns settlement after every write and the read-back.
242
+ */
243
+ async save(): Promise<void> {
244
+ const plan = this.plan()
245
+ const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
246
+ if (plan.length === 0 || this.saving || writes.length !== plan.length) return
247
+ this.saving = true
248
+ this.failed = false
249
+ this.publish()
250
+ let landed = true
251
+ for (const write of writes) {
252
+ landed = await write() && landed
253
+ }
254
+ if (landed) this.staged.clear()
255
+ this.saving = false
256
+ this.failed = !landed
257
+ this.publish()
258
+ }
259
+
260
+ /**
261
+ * Every staged edit a save would write. An entry whose draft is not a value
262
+ * its field accepts carries no write: the form is still dirty, and the save
263
+ * refuses rather than dropping the edit. A staged edit that matches the
264
+ * effective section is not a write at all.
265
+ */
266
+ private plan(): PlannedWrite[] {
267
+ const plan: PlannedWrite[] = []
268
+ for (const [field, staged] of this.staged) {
269
+ const spec = this.specOf(field)
270
+ if (staged.clear) {
271
+ const present = spec.secret === true
272
+ ? (this.options.secretSettled?.(field) ?? false)
273
+ : this.stored(field)
274
+ if (present) plan.push({ field, run: () => this.clear(field) })
275
+ continue
276
+ }
277
+ if (staged.text === spec.format(this.sectionValue(field))) continue
278
+ const write = spec.parse(staged.text)
279
+ if (write === undefined) plan.push({ field, run: undefined })
280
+ else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
281
+ else plan.push({ field, run: () => this.store(field, write.value) })
282
+ }
283
+ return plan
284
+ }
285
+
286
+ private async clear(field: string): Promise<boolean> {
287
+ await this.scope.unset(field)
288
+ const spec = this.specOf(field)
289
+ if (spec.secret === true) return !(this.options.secretSettled?.(field) ?? false)
290
+ return !this.stored(field)
291
+ }
292
+
293
+ private async store(field: string, value: unknown): Promise<boolean> {
294
+ await this.scope.set(field, value)
295
+ const spec = this.specOf(field)
296
+ if (spec.secret === true) return this.options.secretSettled?.(field) ?? true
297
+ return this.userLayer()?.[field] === value
298
+ }
299
+
300
+ private stage(field: string, edit: StagedEdit): void {
301
+ this.staged.set(field, edit)
302
+ this.failed = false
303
+ this.publish()
304
+ }
305
+
306
+ private specOf(field: string): FieldSpec {
307
+ const spec = this.specs.get(field)
308
+ if (spec === undefined) throw new Error(`settings card has no field ${field}`)
309
+ return spec
310
+ }
311
+
312
+ private snapshotOf(): SettingsScopeSnapshot<T> {
313
+ return this.scope.getSnapshot()
314
+ }
315
+
316
+ private sectionValue(field: string): unknown {
317
+ return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
318
+ }
319
+
320
+ private baseValue(field: string): unknown {
321
+ return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
322
+ }
323
+
324
+ private userLayer(): Record<string, unknown> | undefined {
325
+ return this.snapshotOf().user as Record<string, unknown> | undefined
326
+ }
327
+
328
+ private stored(field: string): boolean {
329
+ const user = this.userLayer()
330
+ return user !== undefined && Object.hasOwn(user, field)
331
+ }
332
+
333
+ private publish(): void {
334
+ for (const listener of [...this.listeners]) listener()
335
+ }
336
+ }