@ashley-shrok/viewmodel-shell 1.8.0 → 1.10.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/agent-skill.md CHANGED
@@ -62,6 +62,7 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
62
62
  | `nextPollIn` | Milliseconds. Schedule a `{"name": "poll"}` dispatch after this delay. |
63
63
  | `busy` | Boolean. While `true`, drop user-initiated dispatches. Polls bypass. The next response that omits or sets `false` clears the lock. |
64
64
  | `preventUnload` | Boolean. While `true`, treat the page as having unsaved work — warn before navigating away. |
65
+ | `rejected` | A SOFT (domain/validation) rejection — see below. The action was refused, but `vm`/`state` are still returned. |
65
66
 
66
67
  **Failure:**
67
68
 
@@ -71,6 +72,17 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
71
72
 
72
73
  `errors[].path` and `errors[].code` are optional. HTTP status is 4xx or 5xx on failure. Check `ok` once at the response edge; do not branch on HTTP status.
73
74
 
75
+ **Soft rejections (`rejected`) — important for wire-driving agents.** `ok:false` means the framework could not give you a view. A *domain/validation* rejection is different: the app refused the action (e.g. "targets must be non-negative", "can't remove the only person") but it is still a normal `ok:true` render that preserves the user's input. So `ok` alone does NOT tell you the action succeeded — **on an `ok:true` response, also check for `rejected`.** When present:
76
+
77
+ ```json
78
+ { "ok": true, "vm": { /* … */ }, "state": { /* … */ },
79
+ "rejected": { "violations": [ { "path": "targets.protein", "message": "must be non-negative" } ] } }
80
+ ```
81
+
82
+ - The write **did not take effect.** Do not treat it as success. Surface the violation(s); `vm`/`state` still hold the input the user typed, so you can correct and re-dispatch.
83
+ - Each violation reuses the `errors[]` entry shape (`{ path?, message, code? }`). **`path` is optional**: present → the violation is bound to that field; **absent → it's a form/action-level rejection** with no single field (like the "only person" case).
84
+ - `rejected` only appears on `ok:true`. It never coexists with `ok:false` (that channel carries no view).
85
+
74
86
  ## Side-effect verbs
75
87
 
76
88
  `sideEffects[]` entries each carry a `type` discriminator. Built-in verbs as of `viewmodel-shell/1.0`:
package/dist/browser.js CHANGED
@@ -419,6 +419,26 @@ export class BrowserAdapter {
419
419
  // single-field buttons[]-only form doesn't reload via native submit.
420
420
  form.addEventListener("submit", (e) => e.preventDefault());
421
421
  }
422
+ // Opt-in chat-composer affordance: bare Enter in a descendant textarea
423
+ // dispatches the submit (a textarea otherwise eats Enter as a newline and
424
+ // never submits). Modifier-Enter falls through to a normal newline, and an
425
+ // IME composition Enter (candidate confirmation) must NOT submit. No-op
426
+ // when submitAction is absent. Same dispatch path as the submit button.
427
+ if (n.submitOnEnter && n.submitAction) {
428
+ const submitAction = n.submitAction;
429
+ form.querySelectorAll("textarea").forEach(ta => {
430
+ ta.addEventListener("keydown", (e) => {
431
+ if (e.key !== "Enter")
432
+ return;
433
+ if (e.isComposing || e.keyCode === 229)
434
+ return; // IME candidate confirm — not a send
435
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey)
436
+ return; // newline / shortcut
437
+ e.preventDefault();
438
+ dispatchWithFiles(submitAction);
439
+ });
440
+ });
441
+ }
422
442
  if (n.buttons && n.buttons.length > 0) {
423
443
  const row = document.createElement("div");
424
444
  row.className = "vms-form__buttons";
package/dist/index.d.ts CHANGED
@@ -189,6 +189,10 @@ export interface FormNode {
189
189
  * button, same underlying state. A plain ButtonNode placed in `children`
190
190
  * has identical dispatch semantics; the buttons[] slot is a layout hint. */
191
191
  buttons?: ButtonNode[];
192
+ /** Opt-in: bare Enter inside a descendant <textarea> dispatches submitAction
193
+ * (chat-composer "Enter sends, Shift/Ctrl/Meta/Alt+Enter = newline"). No-op
194
+ * when submitAction is absent or during IME composition. Default false. */
195
+ submitOnEnter?: boolean;
192
196
  children: ViewNode[];
193
197
  }
194
198
  export interface FieldNode {
package/dist/server.d.ts CHANGED
@@ -53,9 +53,29 @@ export interface ShellResponseBody<TState> {
53
53
  * clear the state. Naturally paired with `preventUnload` for long-running
54
54
  * server actions. */
55
55
  busy?: boolean;
56
+ /** A SOFT (domain/validation) rejection that rides on an ok:true render —
57
+ * the action was refused but vm/state are still returned so the form keeps
58
+ * the user's input. Distinct from the ok:false + errors[] channel (which
59
+ * carries NO view): ok:false = "no view for you"; ok:true + rejected =
60
+ * "here's your view back, but the action did not take". An agent driving the
61
+ * wire checks `rejected` IN ADDITION to `ok`. Each violation reuses the
62
+ * ErrorEntry {path?, message, code?} shape; `path` is OPTIONAL — a violation
63
+ * with no path is a form/action-level rejection (vs field-bound when set). */
64
+ rejected?: ShellRejection;
65
+ }
66
+ /** Wrapper for a soft-validation rejection on an ok:true response. */
67
+ export interface ShellRejection {
68
+ violations: ErrorEntry[];
56
69
  }
57
70
  /** Build a redirect response (Vm and State omitted; shell navigates the browser). */
58
71
  export declare function shellRedirect<TState = unknown>(url: string): ShellResponseBody<TState>;
72
+ /** Attach a soft-validation rejection to a normal re-render. Spread alongside
73
+ * vm/state — unlike a redirect, a rejection KEEPS the view:
74
+ * return { vm, state, ...shellRejection([{ path: "x", message: "…" }]) };
75
+ * Mirrors the C# `ShellResponse<T>.WithRejection(...)` fluent helper. */
76
+ export declare function shellRejection(violations: ErrorEntry[]): {
77
+ rejected: ShellRejection;
78
+ };
59
79
  /** Side-effect factories matching the C# ShellSideEffect static methods. */
60
80
  export declare const shellSideEffect: {
61
81
  setLocalStorage: (key: string, value: string) => ShellSideEffect;
package/dist/server.js CHANGED
@@ -379,6 +379,13 @@ export function parseJsonAction(body) {
379
379
  export function shellRedirect(url) {
380
380
  return { redirect: url };
381
381
  }
382
+ /** Attach a soft-validation rejection to a normal re-render. Spread alongside
383
+ * vm/state — unlike a redirect, a rejection KEEPS the view:
384
+ * return { vm, state, ...shellRejection([{ path: "x", message: "…" }]) };
385
+ * Mirrors the C# `ShellResponse<T>.WithRejection(...)` fluent helper. */
386
+ export function shellRejection(violations) {
387
+ return { rejected: { violations } };
388
+ }
382
389
  /** Side-effect factories matching the C# ShellSideEffect static methods. */
383
390
  export const shellSideEffect = {
384
391
  setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "1.8.0",
3
+ "version": "1.10.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 — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -517,11 +517,6 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
517
517
  background: var(--vms-accent-glow);
518
518
  }
519
519
 
520
- @keyframes vms-in {
521
- from { opacity: 0; transform: translateY(4px); }
522
- to { opacity: 1; transform: translateY(0); }
523
- }
524
-
525
520
  /* ── Text ── */
526
521
  .vms-text { flex: 1; line-height: 1.4; word-break: break-word; }
527
522
  .vms-text--heading { font-family: var(--vms-font-head); font-size: var(--vms-text-xl); }
@@ -569,7 +564,6 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
569
564
  justify-content: center;
570
565
  padding: var(--vms-space-md);
571
566
  z-index: 1000;
572
- animation: vms-in 0.15s ease;
573
567
  }
574
568
  .vms-modal {
575
569
  background: var(--vms-surface);