@morlay/dsh-client-ui-primitives 0.0.2-alpha.7 → 0.0.2-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +275 -13
  2. package/dist/client.cjs +4959 -1057
  3. package/dist/client.d.cts +1366 -16
  4. package/dist/client.d.mts +1365 -16
  5. package/dist/rolldown-runtime-EAsCxrDo.mjs +14 -0
  6. package/locale/en.json +1 -1
  7. package/locale/zh.json +1 -1
  8. package/package.json +28 -11
  9. package/src/client/index.ts +41 -2
  10. package/src/client/schema-form/Editor.tsx +777 -0
  11. package/src/client/schema-form/SchemaForm.tsx +77 -0
  12. package/src/client/schema-form/controller.ts +457 -0
  13. package/src/client/schema-form/draft.ts +487 -0
  14. package/src/client/schema-form/fields.tsx +100 -0
  15. package/src/client/schema-form/hints.ts +286 -0
  16. package/src/client/schema-form/index.ts +188 -0
  17. package/src/client/schema-form/labels.ts +30 -0
  18. package/src/client/schema-form/lines.ts +334 -0
  19. package/src/client/schema-form/locales.ts +128 -0
  20. package/src/client/schema-form/rows.ts +153 -0
  21. package/src/client/schema-form/schema-node.ts +812 -0
  22. package/src/client/schema-form/slot-contract.ts +156 -0
  23. package/src/client/schema-form/styles.ts +310 -0
  24. package/src/client/schema-form/testing/fake-describe.ts +45 -0
  25. package/src/client/schema-form/testing/fake-scope.ts +97 -0
  26. package/src/client/schema-form/value.tsx +146 -0
  27. package/src/client/settings-form/SettingsForm.styles.ts +58 -0
  28. package/src/client/settings-form/SettingsForm.tsx +72 -0
  29. package/src/client/settings-form/fields.styles.ts +158 -0
  30. package/src/client/settings-form/fields.tsx +194 -0
  31. package/src/client/settings-form/form-model.ts +421 -0
  32. package/src/client/settings-form/index.ts +19 -0
  33. package/src/client/styling/focus.ts +9 -0
  34. package/src/client/theme.generated.ts +802 -728
  35. package/cordis.patch.yml +0 -3
@@ -0,0 +1,421 @@
1
+ // 搬自 vendor/deepseek-harness/packages/client/ui-primitives/src/settings-form/form-model.ts
2
+ // (上游 `@deepseek-ai/dsh-client-ui-primitives`,见本包 .agents/adrs 里的搬迁记录);逻辑未改,
3
+ // 只有 catch 的未用参数按 lint 口径写成可选绑定。改这里之前先看上游是否也改了同一处。
4
+
5
+ /**
6
+ * The staged form model behind a plugin's settings page.
7
+ *
8
+ * A card stages what the user types and writes it only when they save. Each
9
+ * settings write is a durable, revision-fenced document mutation, so a control
10
+ * that committed as it settled turned one edit into a write the user never
11
+ * asked for and could not preview; staged text makes what is on screen exactly
12
+ * what a save would store.
13
+ *
14
+ * A field shows its effective value — the user layer over the composition
15
+ * layer over the schema default — and whether the user layer carries it. That
16
+ * presence, not a value comparison, is what marks a field overridden: an
17
+ * override equal to the composition default is still an override.
18
+ */
19
+
20
+ import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store";
21
+
22
+ /** What the model reads of one Host entry's form. */
23
+ export interface SettingsFormScopeSnapshot<T> {
24
+ /** `ready` while the Host serves the entry to this client; the form renders nothing otherwise. */
25
+ status: "loading" | "ready" | "unavailable";
26
+ /** Last accepted schema-resolved section; undefined before the first acceptance. */
27
+ value: T | undefined;
28
+ /** Composition layer the value resolves over: what a field reverts to once cleared. */
29
+ base: unknown;
30
+ /** Raw user layer as stored; a field's PRESENCE here is what marks it overridden. */
31
+ user: unknown;
32
+ /** Whether the Host document accepts writes. */
33
+ writable: boolean;
34
+ /** Revision the snapshot was read at; a save fences its mutation with the revision its drafts started from. */
35
+ revision: number | undefined;
36
+ }
37
+
38
+ /** One path edit a save sends, as the shared configuration form's `mutate` accepts it. */
39
+ export type SettingsFormPathOp =
40
+ | { op: "set"; path: readonly string[]; value: unknown }
41
+ | { op: "unset"; path: readonly string[] };
42
+
43
+ /** The entry form the model stages over: the reads and the atomic write of the form `ui-settings` shares per Host entry. */
44
+ export interface SettingsFormScope<T> {
45
+ /** @returns the current sync snapshot. */
46
+ getSnapshot(): SettingsFormScopeSnapshot<T>;
47
+ /**
48
+ * Observe snapshot replacements.
49
+ * @param listener - invoked after each snapshot change.
50
+ * @returns the disposer removing this listener.
51
+ */
52
+ subscribe(listener: () => void): () => void;
53
+ /**
54
+ * Apply ordered field edits in one revision-fenced write.
55
+ * @param ops - the edits, in staging order.
56
+ * @param expectedRevision - the revision the drafts were staged against, when known.
57
+ * @returns true for Host acceptance, false for refusal, after any recovery read.
58
+ */
59
+ mutate(ops: readonly SettingsFormPathOp[], expectedRevision?: number): Promise<boolean>;
60
+ }
61
+
62
+ /** The write one field's staged text performs when the card is saved. */
63
+ export type SettingsFieldWrite = { kind: "set"; value: unknown } | { kind: "clear" };
64
+
65
+ /** How one section field converts between its stored value and its draft text. */
66
+ export interface SettingsFieldSpec {
67
+ /** Field name inside the namespace section. */
68
+ field: string;
69
+ /** Render a stored value as draft text; the empty string when the section carries none. */
70
+ format: (value: unknown) => string;
71
+ /**
72
+ * The write this draft text stages, or undefined when the text is not a
73
+ * value this field accepts — which blocks the save rather than discarding it.
74
+ */
75
+ parse: (text: string) => SettingsFieldWrite | undefined;
76
+ }
77
+
78
+ /**
79
+ * A control whose value is written outside the settings section. A credential
80
+ * literal never rides a response, so its draft has nothing to seed from: it is
81
+ * blank until typed, and a blank draft writes nothing.
82
+ */
83
+ export interface SettingsSecretSpec {
84
+ /** Field name addressing this control inside the card's form. */
85
+ field: string;
86
+ /** Write the staged text; resolves to whether the Host accepted it. */
87
+ write: (text: string) => Promise<boolean>;
88
+ }
89
+
90
+ /** One field as a card's control renders it. */
91
+ export interface SettingsFieldState {
92
+ /** Draft text the control renders. */
93
+ text: string;
94
+ /**
95
+ * Whether saving would leave a user-layer entry for this field. A staged
96
+ * edit answers for itself, so the badge previews the save rather than
97
+ * reporting a state the pending edit already contradicts.
98
+ */
99
+ overridden: boolean;
100
+ /** Whether the draft is not a value this field accepts, which blocks saving. */
101
+ invalid: boolean;
102
+ }
103
+
104
+ /** Form state every plugin card shares. */
105
+ export interface SettingsFormShell {
106
+ /** False while the namespace is not served to this client; the card renders nothing. */
107
+ available: boolean;
108
+ /** Whether the Host document accepts writes. */
109
+ writable: boolean;
110
+ /** Whether the form holds edits that a save would write. */
111
+ dirty: boolean;
112
+ /** Whether any staged draft is invalid, which blocks the save. */
113
+ invalid: boolean;
114
+ /** Whether a save is crossing the wire. */
115
+ saving: boolean;
116
+ /** Whether the last save did not land as staged; cleared by the next edit or save. */
117
+ failed: boolean;
118
+ }
119
+
120
+ /** The write actions every plugin card's slot entry injects. */
121
+ export interface SettingsFormActions {
122
+ /** Stage draft text for one field. */
123
+ edit: (field: string, text: string) => void;
124
+ /** Stage a clear, so saving lets the field re-inherit the composition layer. */
125
+ resetField: (field: string) => void;
126
+ /** Write every staged edit, then re-seed from what the Host accepted. */
127
+ save: () => void;
128
+ /** Drop every staged edit. */
129
+ discard: () => void;
130
+ }
131
+
132
+ /** One field's staged edit. */
133
+ interface StagedEdit {
134
+ /** Draft text the control renders. */
135
+ text: string;
136
+ /** True when this edit clears the field whatever text it shows. */
137
+ clear: boolean;
138
+ }
139
+
140
+ /** One staged edit resolved into the write a save performs. */
141
+ interface PlannedWrite {
142
+ /** Field this entry writes. */
143
+ field: string;
144
+ /**
145
+ * Perform the write and report whether the Host holds the staged value
146
+ * afterwards; undefined when the draft is not a value the field accepts.
147
+ */
148
+ run?: () => Promise<boolean>;
149
+ op?: SettingsFormPathOp;
150
+ }
151
+
152
+ /**
153
+ * A whole-number field. An empty draft clears the field; any other draft that
154
+ * is not a finite number blocks the save.
155
+ * @param field - field name inside the namespace section.
156
+ * @returns the field's conversion spec.
157
+ */
158
+ export function settingsNumberField(field: string): SettingsFieldSpec {
159
+ return {
160
+ field,
161
+ // A section that carries no number for this field renders empty rather
162
+ // than as a value nobody chose.
163
+ format: (value) => (typeof value === "number" ? String(value) : ""),
164
+ parse: (text) => {
165
+ const trimmed = text.trim();
166
+ if (trimmed === "") return { kind: "clear" };
167
+ const parsed = Number(trimmed);
168
+ return Number.isFinite(parsed) ? { kind: "set", value: parsed } : undefined;
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * A free-text field. An empty draft clears the field, so emptying the control
175
+ * and saving is the same gesture as resetting it.
176
+ * @param field - field name inside the namespace section.
177
+ * @returns the field's conversion spec.
178
+ */
179
+ export function settingsTextField(field: string): SettingsFieldSpec {
180
+ return {
181
+ field,
182
+ format: (value) => (typeof value === "string" ? value : ""),
183
+ parse: (text) => {
184
+ const trimmed = text.trim();
185
+ return trimmed === "" ? { kind: "clear" } : { kind: "set", value: trimmed };
186
+ },
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Stages one card's edits over one settings namespace and writes them on save.
192
+ *
193
+ * The form publishes through a snapshot store because slot components read
194
+ * through a snapshot selector, while both the scope and the local drafts
195
+ * change underneath; every projection is rebuilt from the two together.
196
+ */
197
+ export class SettingsFormModel<T> {
198
+ private readonly specs: Map<string, SettingsFieldSpec>;
199
+ private readonly secretSpecs: Map<string, SettingsSecretSpec>;
200
+ private readonly staged = new Map<string, StagedEdit>();
201
+ private readonly listeners = new Set<() => void>();
202
+ private baseline: SettingsFormScopeSnapshot<T> | undefined;
203
+ private readonly unsubscribe: () => void;
204
+ private saving = false;
205
+ private failed = false;
206
+
207
+ /**
208
+ * @param scope - the shared configuration form for this card's namespace.
209
+ * @param specs - the section fields this card edits.
210
+ * @param secrets - the card's write-only controls, written outside the section.
211
+ */
212
+ constructor(
213
+ private readonly scope: SettingsFormScope<T>,
214
+ specs: SettingsFieldSpec[],
215
+ secrets: SettingsSecretSpec[] = [],
216
+ ) {
217
+ this.specs = new Map(specs.map((spec) => [spec.field, spec]));
218
+ this.secretSpecs = new Map(secrets.map((spec) => [spec.field, spec]));
219
+ this.unsubscribe = scope.subscribe(() => {
220
+ this.publish();
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Publish a projection of this form, rebuilt whenever the scope or a draft changes.
226
+ * @param project - build the card's state from the form's current reads.
227
+ * @returns the store the card's component reads through its bound selector.
228
+ */
229
+ bind<S>(project: () => S): SnapshotStore<S> {
230
+ const store = createSnapshotStore(project());
231
+ this.listeners.add(() => {
232
+ store.set(project());
233
+ });
234
+ return store;
235
+ }
236
+
237
+ /**
238
+ * Read the card-level state: what the Host serves, and what a save would do.
239
+ * @returns the form state every card shares.
240
+ */
241
+ shell(): SettingsFormShell {
242
+ const snapshot = this.scope.getSnapshot();
243
+ const plan = this.plan();
244
+ return {
245
+ available: snapshot.status === "ready",
246
+ writable: snapshot.writable,
247
+ dirty: plan.length > 0,
248
+ invalid: plan.some((item) => item.run === undefined && item.op === undefined),
249
+ saving: this.saving,
250
+ failed: this.failed,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Read one control's state.
256
+ * @param field - field name of a section field or of a write-only control.
257
+ * @returns the draft text, whether a save would leave an override, and whether it is invalid.
258
+ */
259
+ field(field: string): SettingsFieldState {
260
+ const staged = this.staged.get(field);
261
+ if (this.secretSpecs.has(field)) {
262
+ return { text: staged?.text ?? "", overridden: false, invalid: false };
263
+ }
264
+ const spec = this.spec(field);
265
+ if (staged === undefined) {
266
+ return {
267
+ text: spec.format(this.sectionValue(field)),
268
+ overridden: this.stored(field),
269
+ invalid: false,
270
+ };
271
+ }
272
+ const write = staged.clear ? { kind: "clear" as const } : spec.parse(staged.text);
273
+ return {
274
+ text: staged.text,
275
+ overridden: write?.kind === "set",
276
+ invalid: write === undefined,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Build the edit, reset, save, and discard actions bound to this form.
282
+ * @returns the actions a card's slot entry injects.
283
+ */
284
+ actions(): SettingsFormActions {
285
+ return {
286
+ edit: (field, text) => {
287
+ this.stage(field, { text, clear: false });
288
+ },
289
+ resetField: (field) => {
290
+ this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true });
291
+ },
292
+ save: () => {
293
+ void this.save();
294
+ },
295
+ discard: () => {
296
+ if (this.staged.size === 0 && !this.failed) return;
297
+ this.staged.clear();
298
+ this.baseline = undefined;
299
+ this.failed = false;
300
+ this.publish();
301
+ },
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Write every staged edit, then re-seed from what the Host accepted.
307
+ *
308
+ * The Host is the only authority on whether a value was accepted — its
309
+ * validators own the constraints no schema can express — so the outcome is
310
+ * read back from the section rather than predicted here. A save that did not
311
+ * land keeps its drafts, so the user can correct them instead of retyping.
312
+ * @returns settlement after every write and the read-back.
313
+ */
314
+ async save(): Promise<void> {
315
+ const plan = this.plan();
316
+ if (
317
+ !plan.length ||
318
+ this.saving ||
319
+ !this.scope.getSnapshot().writable ||
320
+ plan.some((item) => item.run === undefined && item.op === undefined)
321
+ )
322
+ return;
323
+ this.saving = true;
324
+ this.failed = false;
325
+ this.publish();
326
+ try {
327
+ const ops = plan.flatMap((item) => (item.op === undefined ? [] : [item.op]));
328
+ let landed = !ops.length || (await this.scope.mutate(ops, this.baseline?.revision));
329
+ if (!landed) {
330
+ this.failed = true;
331
+ return;
332
+ }
333
+ for (const item of plan) if (item.run) landed = (await item.run()) && landed;
334
+ if (landed) {
335
+ this.staged.clear();
336
+ this.baseline = undefined;
337
+ }
338
+ this.failed = !landed;
339
+ } catch {
340
+ this.failed = true;
341
+ } finally {
342
+ this.saving = false;
343
+ this.publish();
344
+ }
345
+ }
346
+
347
+ /** Release the form's accepted-value subscription. */
348
+ dispose(): void {
349
+ this.unsubscribe();
350
+ this.listeners.clear();
351
+ }
352
+
353
+ /**
354
+ * Every staged edit a save would write. An entry whose draft is not a value
355
+ * its field accepts carries no write: the form is still dirty, and the save
356
+ * refuses rather than dropping the edit.
357
+ * @returns the planned writes, in the order the fields were staged.
358
+ */
359
+ private plan(): PlannedWrite[] {
360
+ const plan: PlannedWrite[] = [];
361
+ for (const [field, staged] of this.staged) {
362
+ const secret = this.secretSpecs.get(field);
363
+ if (secret !== undefined) {
364
+ const value = staged.text.trim();
365
+ if (value !== "") plan.push({ field, run: () => secret.write(value) });
366
+ continue;
367
+ }
368
+ const spec = this.spec(field);
369
+ if (staged.clear) {
370
+ if (this.stored(field)) plan.push({ field, op: { op: "unset", path: [field] } });
371
+ continue;
372
+ }
373
+ if (staged.text === spec.format(this.sectionValue(field))) continue;
374
+ const write = spec.parse(staged.text);
375
+ if (write === undefined) plan.push({ field });
376
+ else if (write.kind === "clear") plan.push({ field, op: { op: "unset", path: [field] } });
377
+ else plan.push({ field, op: { op: "set", path: [field], value: write.value } });
378
+ }
379
+ return plan;
380
+ }
381
+
382
+ private stage(field: string, edit: StagedEdit): void {
383
+ this.baseline ??= this.scope.getSnapshot();
384
+ this.staged.set(field, edit);
385
+ this.failed = false;
386
+ this.publish();
387
+ }
388
+
389
+ private spec(field: string): SettingsFieldSpec {
390
+ const spec = this.specs.get(field);
391
+ // Every call site names a field this card declared; a missing one is a
392
+ // wiring mistake that must not degrade into a silently inert control.
393
+ if (spec === undefined) throw new Error(`plugin card has no field ${field}`);
394
+ return spec;
395
+ }
396
+
397
+ private snapshotOf(): SettingsFormScopeSnapshot<T> {
398
+ return this.scope.getSnapshot();
399
+ }
400
+
401
+ private sectionValue(field: string): unknown {
402
+ return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field];
403
+ }
404
+
405
+ private baseValue(field: string): unknown {
406
+ return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field];
407
+ }
408
+
409
+ private userLayer(): Record<string, unknown> | undefined {
410
+ return this.snapshotOf().user as Record<string, unknown> | undefined;
411
+ }
412
+
413
+ private stored(field: string): boolean {
414
+ const user = this.userLayer();
415
+ return user !== undefined && Object.hasOwn(user, field);
416
+ }
417
+
418
+ private publish(): void {
419
+ for (const listener of this.listeners) listener();
420
+ }
421
+ }
@@ -0,0 +1,19 @@
1
+ // 设置表单原语的 client 出口:模型 + 控件 + 表单框,命名与上游
2
+ // `@deepseek-ai/dsh-client-ui-primitives` 同形(搬迁记录见本包 .agents/adrs/)。
3
+
4
+ export { SettingsFormModel, settingsNumberField, settingsTextField } from "./form-model.ts";
5
+ export type {
6
+ SettingsFieldSpec,
7
+ SettingsFieldState,
8
+ SettingsFieldWrite,
9
+ SettingsFormActions,
10
+ SettingsFormPathOp,
11
+ SettingsFormScope,
12
+ SettingsFormScopeSnapshot,
13
+ SettingsFormShell,
14
+ SettingsSecretSpec,
15
+ } from "./form-model.ts";
16
+ export { SettingsSecretField, SettingsValueField } from "./fields.tsx";
17
+ export type { SettingsFieldProps } from "./fields.tsx";
18
+ export { SettingsForm } from "./SettingsForm.tsx";
19
+ export type { SettingsFormLabels, SettingsFormProps } from "./SettingsForm.tsx";
@@ -0,0 +1,9 @@
1
+ // 焦点环口径搬上游 ui-theme 的 `focus.css`:颜色读官方 `--dsw-focus-ring-color`
2
+ // (主题把它按输入模态解析成蓝,指针模态下置 transparent 抑制环),变量缺失时回退业务蓝;
3
+ // 宽度读官方的 `--dsw-focus-ring-width`。偏移与线宽的特例仍由各控件自己声明。
4
+ import { dsw } from "../theme.ts";
5
+ import { Token } from "./token.ts";
6
+
7
+ export const focusRing = `${String(dsw.focus.ring.width)} solid ${Token.collect(
8
+ Token.fallbackVar(dsw.focus.ring.color, String(dsw.alias.state.business.primary)),
9
+ )}`;