@ashley-shrok/viewmodel-shell 3.0.1 → 3.1.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
@@ -66,6 +66,7 @@ export declare class BrowserAdapter implements Adapter {
66
66
  private button;
67
67
  private text;
68
68
  private link;
69
+ private divider;
69
70
  private statBar;
70
71
  /** TabsNode — on click, write tab.value to state at node.bind, then dispatch
71
72
  * the tab's own action name. */
package/dist/browser.js CHANGED
@@ -238,6 +238,7 @@ export class BrowserAdapter {
238
238
  case "modal": return this.modal(n, parent, on);
239
239
  case "table": return this.table(n, parent, on);
240
240
  case "copy-button": return this.copyButton(n, parent);
241
+ case "divider": return this.divider(n, parent);
241
242
  case "fits": return this.fits(n, parent, on);
242
243
  }
243
244
  }
@@ -510,7 +511,24 @@ export class BrowserAdapter {
510
511
  ev.files = files;
511
512
  on(ev);
512
513
  };
513
- if (n.submitAction) {
514
+ // #22 — submitButton takes precedence: the form renders the consumer's own
515
+ // button (its label + emphasis/tone/size/width) as the submit and fires its
516
+ // action; submitLabel/submitAction for the implicit button are then ignored.
517
+ const sb = n.submitButton;
518
+ const effectiveSubmit = sb ? sb.action : n.submitAction;
519
+ if (sb) {
520
+ const submitAction = sb.action;
521
+ const submit = document.createElement("button");
522
+ submit.type = "submit";
523
+ submit.className = `vms-button${sb.emphasis ? ` vms-button--${sb.emphasis}` : ""}${sb.tone ? ` vms-button--${sb.tone}` : ""}${sb.size ? ` vms-button--${sb.size}` : ""}${sb.width === "full" ? " vms-button--full" : ""}`;
524
+ submit.textContent = sb.label;
525
+ form.appendChild(submit);
526
+ form.addEventListener("submit", (e) => {
527
+ e.preventDefault();
528
+ dispatchWithFiles(submitAction);
529
+ });
530
+ }
531
+ else if (n.submitAction) {
514
532
  const submitAction = n.submitAction;
515
533
  const submit = document.createElement("button");
516
534
  submit.type = "submit";
@@ -532,8 +550,8 @@ export class BrowserAdapter {
532
550
  // never submits). Modifier-Enter falls through to a normal newline, and an
533
551
  // IME composition Enter (candidate confirmation) must NOT submit. No-op
534
552
  // when submitAction is absent. Same dispatch path as the submit button.
535
- if (n.submitOnEnter && n.submitAction) {
536
- const submitAction = n.submitAction;
553
+ if (n.submitOnEnter && effectiveSubmit) {
554
+ const submitAction = effectiveSubmit;
537
555
  form.querySelectorAll("textarea").forEach(ta => {
538
556
  ta.addEventListener("keydown", (e) => {
539
557
  if (e.key !== "Enter")
@@ -774,7 +792,7 @@ export class BrowserAdapter {
774
792
  button(n, parent, on) {
775
793
  const btn = document.createElement("button");
776
794
  btn.type = "button";
777
- btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}`;
795
+ btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
778
796
  btn.textContent = n.label;
779
797
  btn.addEventListener("click", () => {
780
798
  // pendingLabel: instant client-side feedback. Swap text + add
@@ -808,6 +826,20 @@ export class BrowserAdapter {
808
826
  }
809
827
  parent.appendChild(a);
810
828
  }
829
+ divider(n, parent) {
830
+ if (n.orientation === "vertical") {
831
+ // <hr> is semantically horizontal; a vertical rule is a separator div.
832
+ const el = document.createElement("div");
833
+ el.className = "vms-divider vms-divider--vertical";
834
+ el.setAttribute("role", "separator");
835
+ el.setAttribute("aria-orientation", "vertical");
836
+ parent.appendChild(el);
837
+ return;
838
+ }
839
+ const hr = document.createElement("hr"); // implicit role="separator"
840
+ hr.className = "vms-divider";
841
+ parent.appendChild(hr);
842
+ }
811
843
  statBar(n, parent) {
812
844
  const bar = document.createElement("div");
813
845
  bar.className = "vms-stat-bar";
@@ -1142,7 +1174,7 @@ export class BrowserAdapter {
1142
1174
  copyButton(n, parent) {
1143
1175
  const btn = document.createElement("button");
1144
1176
  btn.type = "button";
1145
- btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}`;
1177
+ btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
1146
1178
  btn.textContent = n.label ?? "Copy";
1147
1179
  btn.addEventListener("click", () => {
1148
1180
  const write = navigator.clipboard?.writeText(n.text);
package/dist/index.d.ts CHANGED
@@ -63,7 +63,7 @@ export interface Adapter {
63
63
  * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
64
64
  setBusy?(active: boolean): void;
65
65
  }
66
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | FitsNode;
66
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode;
67
67
  export interface PageNode {
68
68
  type: "page";
69
69
  title?: string;
@@ -203,6 +203,15 @@ export interface FormNode {
203
203
  * FieldNode.action still fires per-field). */
204
204
  submitAction?: ActionEvent;
205
205
  submitLabel?: string;
206
+ /** Full control of the submit button (#22). When set, the form renders THIS
207
+ * button as its submit — carrying its own `label` + `emphasis`/`tone`/`size`/
208
+ * `width`/`pendingLabel` — instead of synthesizing a plain one from
209
+ * `submitLabel`. The form fires `submitButton.action` on click, on native
210
+ * submit (Enter in a text field), and on textarea Enter (`submitOnEnter`).
211
+ * Mirrors the universal "write your own submit button" pattern (e.g. a
212
+ * full-width submit: `width:"full"`). Takes precedence over
213
+ * `submitLabel`/`submitAction`, which are ignored for the button when set. */
214
+ submitButton?: ButtonNode;
206
215
  /** Layout preset for the form's controls. Omitted or "stack" = fields stacked (current, no modifier class). "inline" = field row + submit on one line (add/search bar) — emits .vms-form--inline. Closed union (D-29). */
207
216
  layout?: "stack" | "inline";
208
217
  /** Multi-action submit buttons (#15). Each is a full ButtonNode (so
@@ -261,6 +270,11 @@ export interface ButtonNode {
261
270
  tone?: "danger" | "warning" | "success" | "info";
262
271
  /** Box geometry (padding + font), orthogonal to color/emphasis — the one axis no design system bakes into variant. Emits .vms-button--{size}. Omitted = the default (md) size. Closed union. */
263
272
  size?: "sm" | "lg";
273
+ /** Width axis — `"full"` stretches the button to fill its container's cross
274
+ * axis (the standard full-width / "block" button: MUI `fullWidth`, Ant
275
+ * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
276
+ * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
277
+ width?: "auto" | "full";
264
278
  /** Transient label shown from click until the dispatch resolves (response
265
279
  * arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
266
280
  * lifecycle pattern at a different beat: shown DURING the round-trip
@@ -431,6 +445,11 @@ export interface CopyButtonNode {
431
445
  tone?: "danger" | "warning" | "success" | "info";
432
446
  /** Box geometry — mirrors ButtonNode.size. Emits .vms-button--{size}. Omitted = md. Closed union. */
433
447
  size?: "sm" | "lg";
448
+ /** Width axis — `"full"` stretches the button to fill its container's cross
449
+ * axis (the standard full-width / "block" button: MUI `fullWidth`, Ant
450
+ * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
451
+ * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
452
+ width?: "auto" | "full";
434
453
  }
435
454
  /**
436
455
  * The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
@@ -463,6 +482,14 @@ export interface CopyButtonNode {
463
482
  * meaningful. For list/detail and similar text panes use `split` / `sidebar`,
464
483
  * which collapse to a single column intrinsically on their own (zero @media).
465
484
  */
485
+ /** A thematic break / separator (#22) — the standard divider primitive (MUI/Ant
486
+ * `<Divider>`, Radix `<Separator>`). Horizontal (default) renders an `<hr>` with
487
+ * its implicit `role="separator"`; vertical renders a `role="separator"` div with
488
+ * `aria-orientation="vertical"` for row layouts. No content. */
489
+ export interface DividerNode {
490
+ type: "divider";
491
+ orientation?: "horizontal" | "vertical";
492
+ }
466
493
  export interface FitsNode {
467
494
  type: "fits";
468
495
  /** Axis on which the container's fit is tested. CLOSED union; OMITTED =
package/dist/tui.js CHANGED
@@ -750,6 +750,7 @@ function renderNode(node, ctx, key) {
750
750
  case "stat-bar": return _jsx(StatBarView, { node: node }, key);
751
751
  case "modal": return _jsx(ModalView, { node: node, ctx: ctx }, key);
752
752
  case "copy-button": return _jsx(CopyButtonView, { node: node, ctx: ctx }, key);
753
+ case "divider": return _jsx("text", { fg: "#555555", children: node.orientation === "vertical" ? "│" : "─".repeat(40) }, key);
753
754
  case "form": return _jsx(FormView, { node: node, ctx: ctx }, key);
754
755
  case "field": return _jsx(FieldView, { node: node, ctx: ctx }, key);
755
756
  case "fits": {
@@ -1158,7 +1159,7 @@ function FormView({ node, ctx }) {
1158
1159
  ctx.onAction({ name: base.name });
1159
1160
  };
1160
1161
  // Enter-in-a-field submits the default action — only wired when present.
1161
- const submitAction = node.submitAction;
1162
+ const submitAction = node.submitButton ? node.submitButton.action : node.submitAction;
1162
1163
  const childCtx = {
1163
1164
  ...ctx,
1164
1165
  isTopLevel: false,
@@ -1171,7 +1172,7 @@ function FormView({ node, ctx }) {
1171
1172
  // Layout preset on form: "stack" (default — fields stacked) or "inline"
1172
1173
  // (field row + submit on one line, the add-bar/search-bar pattern).
1173
1174
  const isInline = node.layout === "inline";
1174
- return (_jsxs("box", { flexDirection: isInline ? "row" : "column", gap: 1, children: [node.children.map((child, i) => renderNode(child, childCtx, i)), submitAction != null ? (_jsxs("text", { attributes: 1 /* BOLD */, children: ["[ ", node.submitLabel ?? "Submit", " ]"] })) : null, node.buttons && node.buttons.length > 0 ? (_jsx("box", { flexDirection: "row", gap: 2, children: node.buttons.map((btn, i) => (_jsx(ButtonView, { node: btn, ctx: buttonCtx }, `b${i}`))) })) : null] }));
1175
+ return (_jsxs("box", { flexDirection: isInline ? "row" : "column", gap: 1, children: [node.children.map((child, i) => renderNode(child, childCtx, i)), submitAction != null ? (_jsxs("text", { attributes: 1 /* BOLD */, children: ["[ ", node.submitButton ? node.submitButton.label : (node.submitLabel ?? "Submit"), " ]"] })) : null, node.buttons && node.buttons.length > 0 ? (_jsx("box", { flexDirection: "row", gap: 2, children: node.buttons.map((btn, i) => (_jsx(ButtonView, { node: btn, ctx: buttonCtx }, `b${i}`))) })) : null] }));
1175
1176
  }
1176
1177
  // ── field ─────────────────────────────────────────────────────────────────
1177
1178
  // Real OpenTUI input/textarea/select wired to the adapter's field state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.0.1",
3
+ "version": "3.1.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",
@@ -61,6 +61,13 @@
61
61
  --vms-text-lg: 1.125rem;
62
62
  --vms-text-xl: 1.5rem;
63
63
  --vms-text-2xl: 2.25rem;
64
+ /* Shared line-box height for the default-size form controls + buttons. An
65
+ ABSOLUTE value (not a font-relative ratio) so an input (--vms-text-md) and a
66
+ button (--vms-text-base) — which intentionally differ in font size — compute
67
+ to the SAME height when their padding + border match, and therefore line up
68
+ pixel-for-pixel in a filter/toolbar row. Size variants (.vms-button--sm/lg)
69
+ and compact icon buttons set their own line-height. */
70
+ --vms-control-line: 1.5rem;
64
71
  /* Page shell (D-12) — additive override seam (issue #13); host/theme-retunable
65
72
  via a single :root{} override imported after the theme. The two width
66
73
  ratchets — --vms-page-max-wide (1440px default) and --vms-page-max-full
@@ -434,6 +441,7 @@ body {
434
441
  color: var(--vms-text);
435
442
  font-family: var(--vms-font-body);
436
443
  font-size: var(--vms-text-md);
444
+ line-height: var(--vms-control-line);
437
445
  padding: var(--vms-space-sm) var(--vms-space-md);
438
446
  outline: none;
439
447
  transition: border-color var(--vms-t), box-shadow var(--vms-t);
@@ -526,6 +534,7 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
526
534
  cursor: pointer;
527
535
  font-family: var(--vms-font-body);
528
536
  font-size: var(--vms-text-base);
537
+ line-height: var(--vms-control-line);
529
538
  padding: var(--vms-space-sm) var(--vms-space-md);
530
539
  align-self: flex-start;
531
540
  transition: background var(--vms-t), border-color var(--vms-t), transform var(--vms-t);
@@ -555,9 +564,20 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
555
564
  }
556
565
  .vms-button--secondary:hover { background: var(--vms-accent-glow); }
557
566
 
558
- /* size — the ONLY axis that touches box metrics (md = base, no class). */
559
- .vms-button--sm { padding: var(--vms-space-2xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
560
- .vms-button--lg { padding: var(--vms-space-sm) var(--vms-space-lg); font-size: var(--vms-text-lg); }
567
+ /* size — the ONLY axis that touches box metrics (md = base, no class). Each size
568
+ sets its own line-box so sm stays compact and lg reads larger (the default-size
569
+ control line lives on the base rule + --vms-control-line). */
570
+ .vms-button--sm { padding: var(--vms-space-2xs) var(--vms-space-sm); font-size: var(--vms-text-sm); line-height: 1.125rem; }
571
+ .vms-button--lg { padding: var(--vms-space-sm) var(--vms-space-lg); font-size: var(--vms-text-lg); line-height: 1.75rem; }
572
+ /* width — the standard full-width / "block" button (#22). Stretches to fill the
573
+ container's cross axis (overriding the base align-self:flex-start), so a form
574
+ submit lines up with a column of width:100% inputs. */
575
+ .vms-button--full { align-self: stretch; width: 100%; }
576
+
577
+ /* ── Divider / separator (#22) — the standard thematic break. Horizontal is an
578
+ <hr>; vertical is a role="separator" div for row layouts. ── */
579
+ .vms-divider { border: none; border-top: 1px solid var(--vms-border); width: 100%; margin-block: var(--vms-space-sm); }
580
+ .vms-divider--vertical { border-top: none; border-left: 1px solid var(--vms-border); width: 0; align-self: stretch; margin: 0 var(--vms-space-sm); }
561
581
 
562
582
  /* ── Pending state (0.8.0 / issue #11) ──
563
583
  Applied by BrowserAdapter when a ButtonNode with `pendingLabel` is clicked.
@@ -578,6 +598,9 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
578
598
  color: var(--vms-text-muted);
579
599
  opacity: 0;
580
600
  padding: var(--vms-space-2xs) var(--vms-space-xs);
601
+ /* compact inline icon affordance — opt out of the standardized control line so
602
+ the row's delete-X stays small, not control-height. */
603
+ line-height: 1.25rem;
581
604
  border-radius: var(--vms-radius-sm);
582
605
  transition: opacity var(--vms-t), color var(--vms-t), background var(--vms-t);
583
606
  }