@ashley-shrok/viewmodel-shell 3.8.0 → 3.9.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/dist/browser.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare class BrowserAdapter implements Adapter {
3
3
  private container;
4
4
  private fileRegistry;
5
5
  private sa;
6
+ private diagWarned;
6
7
  private detailsOpenSnapshot;
7
8
  private sectionKeyCounter;
8
9
  private fitsObservers;
@@ -77,6 +78,15 @@ export declare class BrowserAdapter implements Adapter {
77
78
  /** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
78
79
  * When `action` is set, it fires on Enter (text-like) or change (select) —
79
80
  * the new value is already in state by that point. */
81
+ /** Warn to the dev console at most once per key (deduped over this adapter's
82
+ * lifetime). Fires in dev AND prod — the client bundle can't tell them apart,
83
+ * and prod telemetry that captures console.warn should see these too. */
84
+ private warnOnce;
85
+ /** Read a bind path, tolerating a bind-less field (file inputs) — null bind
86
+ * reads nothing. */
87
+ private readBind;
88
+ /** Write to a bind path, no-op when the field has no bind (file inputs). */
89
+ private writeBind;
80
90
  private field;
81
91
  /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
82
92
  * render help + error text below it, wiring aria-describedby / aria-invalid.
package/dist/browser.js CHANGED
@@ -46,6 +46,11 @@ export class BrowserAdapter {
46
46
  container;
47
47
  fileRegistry = new Map();
48
48
  sa = noopStateAccess;
49
+ // Dev-console diagnostics dedup (3.9.0). Both [vms:no-bind] and
50
+ // [vms:type-mismatch] warn at most once per key over this adapter's lifetime.
51
+ // The client bundle can't distinguish dev/prod, so these fire in both — which
52
+ // is intentional: prod telemetry that captures console.warn sees them too.
53
+ diagWarned = new Set();
49
54
  // 1.2.0 — open-state snapshot for SectionNode.collapsible. Captured by
50
55
  // render() BEFORE this.container.innerHTML = "" by walking
51
56
  // [data-section-key] details elements; consumed by render() AFTER node()
@@ -675,8 +680,35 @@ export class BrowserAdapter {
675
680
  /** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
676
681
  * When `action` is set, it fires on Enter (text-like) or change (select) —
677
682
  * the new value is already in state by that point. */
683
+ /** Warn to the dev console at most once per key (deduped over this adapter's
684
+ * lifetime). Fires in dev AND prod — the client bundle can't tell them apart,
685
+ * and prod telemetry that captures console.warn should see these too. */
686
+ warnOnce(key, msg) {
687
+ if (!this.diagWarned.has(key)) {
688
+ this.diagWarned.add(key);
689
+ console.warn(msg);
690
+ }
691
+ }
692
+ /** Read a bind path, tolerating a bind-less field (file inputs) — null bind
693
+ * reads nothing. */
694
+ readBind(bind) {
695
+ return bind == null ? undefined : this.sa.read(bind);
696
+ }
697
+ /** Write to a bind path, no-op when the field has no bind (file inputs). */
698
+ writeBind(bind, value) {
699
+ if (bind != null)
700
+ this.sa.write(bind, value);
701
+ }
678
702
  field(n, parent, on) {
679
- const stateValue = this.sa.read(n.bind);
703
+ const stateValue = this.readBind(n.bind);
704
+ // [vms:no-bind] — a value-bearing input with no bind renders but silently
705
+ // drops user input (nothing to persist to). Exclude `file` (bind is
706
+ // legitimately optional — the binary rides multipart) and `hidden`
707
+ // (server-authoritative, no user input).
708
+ if (n.inputType !== "file" && n.inputType !== "hidden" && n.bind == null) {
709
+ this.warnOnce("no-bind:" + n.name, "[vms:no-bind] FieldNode '" + n.name + "' (inputType=" + n.inputType +
710
+ ") has no bind — value-bearing inputs need a bind path to persist; the field renders but user input is dropped.");
711
+ }
680
712
  if (n.inputType === "hidden") {
681
713
  // Hidden fields don't write back — server is authoritative for hidden.
682
714
  const inp = document.createElement("input");
@@ -699,7 +731,7 @@ export class BrowserAdapter {
699
731
  inp.name = n.name;
700
732
  inp.checked = Boolean(stateValue);
701
733
  inp.addEventListener("change", () => {
702
- this.sa.write(n.bind, inp.checked);
734
+ this.writeBind(n.bind, inp.checked);
703
735
  });
704
736
  wrapper.appendChild(inp);
705
737
  if (n.label) {
@@ -752,19 +784,19 @@ export class BrowserAdapter {
752
784
  // explicit empty the server can reject, never a silently-missing key.
753
785
  if (isMulti) {
754
786
  if (!Array.isArray(stateValue)) {
755
- this.sa.write(n.bind, Array.from(sel.selectedOptions, o => o.value));
787
+ this.writeBind(n.bind, Array.from(sel.selectedOptions, o => o.value));
756
788
  }
757
789
  }
758
790
  else if (stateValue === undefined || String(stateValue) !== sel.value) {
759
- this.sa.write(n.bind, sel.value);
791
+ this.writeBind(n.bind, sel.value);
760
792
  }
761
793
  sel.addEventListener("change", () => {
762
794
  if (isMulti) {
763
795
  const arr = Array.from(sel.selectedOptions).map(o => o.value);
764
- this.sa.write(n.bind, arr);
796
+ this.writeBind(n.bind, arr);
765
797
  }
766
798
  else {
767
- this.sa.write(n.bind, sel.value);
799
+ this.writeBind(n.bind, sel.value);
768
800
  }
769
801
  if (n.action)
770
802
  on({ name: n.action.name });
@@ -791,14 +823,31 @@ export class BrowserAdapter {
791
823
  const file = inp.files?.[0];
792
824
  if (file) {
793
825
  this.fileRegistry.set(n.name, file);
826
+ // [vms:type-mismatch] — OBSERVABLE-SUBSET diagnostic. The client is
827
+ // untyped JS: it CANNOT know a state slot's *declared* server type, so
828
+ // it only catches the observable case where a file object overwrites a
829
+ // slot that already holds a scalar. It does NOT catch an empty/null slot
830
+ // typed string-map server-side — certain detection of that is a
831
+ // server-side `_state` deserialize diagnostic (a separate follow-up).
832
+ if (n.bind != null) {
833
+ const existing = this.readBind(n.bind);
834
+ if (existing != null && typeof existing !== "object") {
835
+ this.warnOnce("type-mismatch:" + n.name + ":" + n.bind, "[vms:type-mismatch] file FieldNode '" + n.name +
836
+ "' writes a {filename,size} object into bind '" + n.bind +
837
+ "', whose current state value is a " + (typeof existing) +
838
+ " — if that slot is typed string/string-map the _state round-trip will FAIL (cannot convert object to String). Give the file field an object-typed slot, or omit bind (the file rides multipart regardless).");
839
+ }
840
+ }
794
841
  // Per Phase-6 decision: the picked file is visible in state as a
795
842
  // serialization-safe placeholder; the binary travels on the
796
- // multipart side channel.
797
- this.sa.write(n.bind, { filename: file.name, size: file.size });
843
+ // multipart side channel. Backward-compat: apps binding a file field
844
+ // to an object slot still get the placeholder. A bind-less file input
845
+ // writes nothing (writeBind no-ops) — the binary rides multipart.
846
+ this.writeBind(n.bind, { filename: file.name, size: file.size });
798
847
  }
799
848
  else {
800
849
  this.fileRegistry.delete(n.name);
801
- this.sa.write(n.bind, null);
850
+ this.writeBind(n.bind, null);
802
851
  }
803
852
  });
804
853
  wrapper.appendChild(inp);
@@ -813,7 +862,7 @@ export class BrowserAdapter {
813
862
  ta.value = stateValue == null ? "" : String(stateValue);
814
863
  if (n.required)
815
864
  ta.required = true;
816
- ta.addEventListener("input", () => { this.sa.write(n.bind, ta.value); });
865
+ ta.addEventListener("input", () => { this.writeBind(n.bind, ta.value); });
817
866
  wrapper.appendChild(ta);
818
867
  }
819
868
  else if (n.inputType === "code") {
@@ -836,7 +885,7 @@ export class BrowserAdapter {
836
885
  ta.value = stateValue == null ? "" : String(stateValue);
837
886
  if (n.required)
838
887
  ta.required = true;
839
- ta.addEventListener("input", () => { this.sa.write(n.bind, ta.value); });
888
+ ta.addEventListener("input", () => { this.writeBind(n.bind, ta.value); });
840
889
  ta.addEventListener("keydown", (e) => {
841
890
  if (e.key === "Tab") {
842
891
  e.preventDefault();
@@ -844,7 +893,7 @@ export class BrowserAdapter {
844
893
  const end = ta.selectionEnd ?? 0;
845
894
  ta.value = ta.value.slice(0, start) + "\t" + ta.value.slice(end);
846
895
  ta.selectionStart = ta.selectionEnd = start + 1;
847
- this.sa.write(n.bind, ta.value);
896
+ this.writeBind(n.bind, ta.value);
848
897
  }
849
898
  });
850
899
  wrapper.appendChild(ta);
@@ -860,7 +909,7 @@ export class BrowserAdapter {
860
909
  inp.value = stateValue == null ? "" : String(stateValue);
861
910
  if (n.required)
862
911
  inp.required = true;
863
- inp.addEventListener("input", () => { this.sa.write(n.bind, inp.value); });
912
+ inp.addEventListener("input", () => { this.writeBind(n.bind, inp.value); });
864
913
  if (n.action) {
865
914
  const action = n.action;
866
915
  inp.addEventListener("keydown", (e) => {
@@ -869,7 +918,7 @@ export class BrowserAdapter {
869
918
  // Belt-and-suspenders: flush the latest value to state before
870
919
  // dispatching, in case the browser hasn't fired `input` yet
871
920
  // (e.g. an autofill that lands then submits).
872
- this.sa.write(n.bind, inp.value);
921
+ this.writeBind(n.bind, inp.value);
873
922
  on({ name: action.name });
874
923
  }
875
924
  });
package/dist/index.d.ts CHANGED
@@ -267,8 +267,15 @@ export interface FieldNode {
267
267
  type: "field";
268
268
  name: string;
269
269
  inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
270
- /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
271
- bind: string;
270
+ /** Path into state where this input reads its current value and writes user
271
+ * changes (e.g. `fields.title`). REQUIRED for value-bearing inputs
272
+ * (text/email/password/number/date/time/datetime-local/textarea/select/
273
+ * select-multiple/checkbox/code) and OPTIONAL for `file` inputs — a file
274
+ * input's binary rides the multipart side channel (fileRegistry keyed on
275
+ * `name`), so omit `bind` on a file input to avoid writing a
276
+ * `{filename,size}` placeholder object into state (which breaks a
277
+ * string/string-map state slot on round-trip). */
278
+ bind?: string;
272
279
  label?: string;
273
280
  placeholder?: string;
274
281
  required?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",