@lmzhen/dsh-evolution-settings-ui 0.7.0 → 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/README.md CHANGED
@@ -16,12 +16,20 @@ The cards claim the keyed child slot per namespace, so a namespace the Host does
16
16
 
17
17
  ## What a card does
18
18
 
19
- A card renders the namespace's user-writable (E3) fields with the registry's Chinese label, its unit, a 用户/部署 source chip, a control **typed from the registry** (switch / select / number / text) holding the current value, the registry's hint, and a per-field 恢复部署默认 action when a user override exists. The card's foot carries one 放弃修改 / 保存 pair: edits live in a draft, 保存 writes only the dirty fields in order and clears the draft on success. Success is decided by READING BACK the raw user section — the client settings scope resolves a refused write (it recovers the snapshot and returns; the remote call never rejects), so a field the Host refused simply leaves no user key. When that happens the card reports it under the fields and keeps the draft, so nothing the operator typed is lost and a refusal never looks like a silent revert.
19
+ A card renders the namespace's user-writable (E3) fields with the registry's Chinese label, its unit, a 用户/部署 source chip, a control **typed from the registry** (switch / select / number / text) holding the current value, the registry's hint, and a per-field 恢复部署默认 action when a user override exists. The card's foot carries one 放弃修改 / 保存 pair: edits live in a draft, 保存 writes only the dirty fields in order, and a settling effect clears the draft once the Host kept every written key.
20
+
21
+ Success is never assumed from the write call: the client settings scope RESOLVES a refused write (it recovers the snapshot and returns; the remote call never rejects). Both scopes this bundle runs on — the platform's own controller and the bridge variant — finish that recovery read BEFORE the write promise resolves, so the raw user section read in the settling effect is already the verdict: a field the Host refused leaves no user key. The card then reports the refusal under the fields and keeps the draft, so nothing the operator typed is lost and a refusal never looks like a silent revert. The verdict cannot be read from the save callback itself: the raw snapshot is reachable only through the render-time seat (see the next section).
20
22
 
21
23
  Writes go through the client settings scope (`set`/`unset`), which carries the revision it read as the write's fence; a field whose key is present in the raw user section reads as a user override even when its value equals the deployment value.
22
24
 
23
25
  The field list AND the per-field UI metadata are GENERATED from the parameter registry (`packages/scripts/gen-param-client-view.mjs` writes `src/client/generated-params.ts`): id, group, the English summary, and the E3 rows' label / hint / control / unit / values. The browser half therefore cannot name a parameter the Host does not register, and cannot invent a control the registry does not declare. Regenerate after every registry edit; the family gate runs the generator with `--check`.
24
26
 
27
+ ## The `hooks` compartment never reaches the component
28
+
29
+ A card's inject face carries `hooks: { paramSection: source }` — that is the SHELL's seat, not a prop. The renderer binds each entry to a `use<Name>` seat and hands the component the face with `hooks` REMOVED (the slot contract is `PropsHooks<face['hooks']>` plus `Omit<face, 'hooks'>`). Reading `props.hooks` therefore reads a prop that never exists, and the failure only shows up at runtime, inside the save path.
30
+
31
+ The props type is spelled the way the renderer derives it — `Omit<ParamCardFace, 'hooks'> & { useParamSection }` — so re-introducing that mistake fails `tsc` instead of failing the operator's save. 0.7.0 shipped the mistake; 0.7.1 fixed it and derived the type this way.
32
+
25
33
  ## Styling
26
34
 
27
35
  The design system's CSS is not exported to packages outside the platform repository, so this bundle carries its own stylesheet and injects it once behind a `<style data-plugin-css="…">` tag — the mechanism the platform's own client bundles use. Every rule reads a `--dsw-alias-*` design token, so light and dark follow the theme without a colour of our own, and the field metrics (12px padding, 6px gap, 13px label, 12px hint) copy the platform's settings fields.
@@ -40,3 +48,4 @@ The browser half ships as the client module system's lazy CJS factory artifact (
40
48
  - The section renders plain controls styled with the design tokens rather than the platform's `@deepseek-ai/dsh-client-ui-primitives` kit: that module IS available to out-of-repo bundles (the market plugin requires it), but its prop shapes are not published, and guessing them would break the live GUI.
41
49
  - An unsaved draft lives in the card's own component state and the settings shell renders only the active section, so switching to another section drops a draft that was not saved yet. Values already written are unaffected; moving the draft into an apply-time store is the 0.7.x follow-up.
42
50
  - A deployment that overrides a field through the `evolution-policy` row does not show on the card: the card reports the deployment value the settings scope serves, while the policy snapshot can differ. That divergence stays a doctor / `/evolution params` matter.
51
+ - The browser half has no rendered spec: the family specs are Node-level, so this package's client code is covered by `tsc`, the bundle build and the gate steps, plus a live pass on the installed artifact (0.7.0's save defect was found exactly there). A spec that renders the card with the props the renderer actually builds — no `hooks`, the bound seat stubbed — is the follow-up that catches this verdict path in CI.
package/lib/client.js CHANGED
@@ -435,6 +435,29 @@ window.__ModuleLoader__.load({
435
435
  return language === "zh" ? zh[key] : en[key];
436
436
  }
437
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
438
461
  //#region src/client/ParamCard.ts
439
462
  /**
440
463
  * One namespace's parameter card.
@@ -481,12 +504,6 @@ window.__ModuleLoader__.load({
481
504
  function isSection(value) {
482
505
  return typeof value === "object" && value !== null && !Array.isArray(value);
483
506
  }
484
- /** Let the settings store settle one write before the read-back (it batches updates). */
485
- async function settle() {
486
- await new Promise((done) => {
487
- setTimeout(done, 60);
488
- });
489
- }
490
507
  /** One field's control, typed from the registry. */
491
508
  function FieldControl(props) {
492
509
  const { field, text } = props;
@@ -554,12 +571,14 @@ window.__ModuleLoader__.load({
554
571
  const [draft, setDraft] = (0, react.useState)({});
555
572
  const [busy, setBusy] = (0, react.useState)(false);
556
573
  const [error, setError] = (0, react.useState)("");
574
+ const [pending, setPending] = (0, react.useState)(null);
557
575
  const snapshot = props.useParamSection((state) => state);
558
576
  if (snapshot.status === "loading") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("loading"));
559
577
  if (snapshot.status === "unavailable") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("unavailable"));
560
578
  const user = isSection(snapshot.user) ? snapshot.user : {};
561
579
  const value = isSection(snapshot.value) ? snapshot.value : {};
562
580
  const disabled = !snapshot.writable;
581
+ const locked = disabled || busy;
563
582
  const titleKey = NAMESPACE_TITLES[namespace];
564
583
  const title = titleKey === void 0 ? namespace : t(titleKey);
565
584
  const changed = fields.filter((field) => Object.hasOwn(user, field.id)).length;
@@ -571,27 +590,43 @@ window.__ModuleLoader__.load({
571
590
  [id]: next
572
591
  });
573
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
+ ]);
574
617
  const save = () => {
575
618
  setBusy(true);
576
619
  setError("");
577
620
  (async () => {
578
621
  try {
579
622
  for (const field of dirty) await write(field.id, parseFor(field.control, textOf(field)));
580
- let landed = isSection(props.hooks.paramSection.getSnapshot().user) ? props.hooks.paramSection.getSnapshot().user : {};
581
- for (let attempt = 0; attempt < 4 && dirty.some((field) => !Object.hasOwn(landed, field.id)); attempt += 1) {
582
- await settle();
583
- const fresh = props.hooks.paramSection.getSnapshot();
584
- landed = isSection(fresh.user) ? fresh.user : {};
585
- }
586
- if (dirty.some((field) => !Object.hasOwn(landed, field.id))) {
587
- setError(t("refused"));
588
- return;
589
- }
590
- setDraft({});
623
+ setPending(dirty.map((field) => ({
624
+ id: field.id,
625
+ want: parseFor(field.control, textOf(field))
626
+ })));
591
627
  } catch (caught) {
592
- setError(caught instanceof Error && caught.message !== "" ? caught.message : t("refused"));
593
- } finally {
594
628
  setBusy(false);
629
+ setError(caught instanceof Error && caught.message !== "" ? caught.message : t("refused"));
595
630
  }
596
631
  })();
597
632
  };
@@ -607,7 +642,7 @@ window.__ModuleLoader__.load({
607
642
  field,
608
643
  text: textOf(field),
609
644
  overridden: Object.hasOwn(user, field.id),
610
- disabled,
645
+ disabled: locked,
611
646
  t,
612
647
  onChange: change,
613
648
  clear
@@ -17,7 +17,14 @@ import { type ReactNode } from 'react';
17
17
  import type { ClientParamField } from './generated-params.ts';
18
18
  import { type MessageKey } from './messages.ts';
19
19
  import type { ParamSectionSnapshot, ParamSectionSource } from './seam.ts';
20
- /** Injected face: plain data and callbacks, plus the hook seat. */
20
+ /**
21
+ * Injected face: plain data and callbacks, plus the hook seat.
22
+ *
23
+ * The `hooks` compartment belongs to the shell, not to the component: the
24
+ * renderer binds its entries to `use<Name>` props and omits `hooks` from what
25
+ * the component receives. A card that reads `props.hooks` therefore reads a prop
26
+ * that never exists.
27
+ */
21
28
  export interface ParamCardFace {
22
29
  namespace: string;
23
30
  fields: readonly ClientParamField[];
@@ -28,8 +35,12 @@ export interface ParamCardFace {
28
35
  paramSection: ParamSectionSource;
29
36
  };
30
37
  }
31
- /** Props the renderer binds for one card: the inject face plus its hook seat. */
32
- export type ParamCardProps = ParamCardFace & {
38
+ /**
39
+ * Props the renderer binds for one card: the inject face minus its hook compartment,
40
+ * plus the seats bound from it. Spelled the way the shell derives it, so reaching for
41
+ * `props.hooks` fails the type check instead of failing at runtime.
42
+ */
43
+ export type ParamCardProps = Omit<ParamCardFace, 'hooks'> & {
33
44
  /** Bound from \`hooks.paramSection\` by the renderer. */
34
45
  readonly useParamSection: <T>(selector: (state: ParamSectionSnapshot) => T) => T;
35
46
  };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The save verdict: did the Host take the values this card asked for?
3
+ *
4
+ * A write promise that resolves is not a verdict. Both settings scopes this bundle runs
5
+ * on finish a recovery read before it resolves, so a value the Host refused is simply
6
+ * still the previous one. Presence in the user layer cannot tell refusal from acceptance
7
+ * on a field the operator had already overridden — the refused value's key is present
8
+ * too — so the verdict compares what each staged id holds now against what was requested.
9
+ * @module @lmzhen/dsh-evolution-settings-ui
10
+ */
11
+ /** One staged write awaiting its verdict. */
12
+ export interface PendingWrite {
13
+ /** Parameter id the write named. */
14
+ readonly id: string;
15
+ /** The value handed to the scope's `set`. */
16
+ readonly want: unknown;
17
+ }
18
+ /**
19
+ * Whether every staged write is visible in the user layer.
20
+ * @param pending - the writes awaiting a verdict.
21
+ * @param user - the user layer of the post-write snapshot; a non-section reads as empty.
22
+ * @returns true when each staged id now holds the value that was requested.
23
+ */
24
+ export declare function landedWrites(pending: readonly PendingWrite[], user: unknown): boolean;
25
+ //# sourceMappingURL=settle.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-settings-ui",
3
3
  "description": "Web settings cards for the evolution family's parameter namespaces (community build)",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },