@ashley-shrok/viewmodel-shell 3.0.2 → 3.2.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 +6 -2
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +40 -8
- package/dist/index.d.ts +32 -1
- package/dist/tui.js +3 -2
- package/package.json +1 -1
- package/styles/default.css +27 -0
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# @ashley-shrok/viewmodel-shell
|
|
2
2
|
|
|
3
|
-
A server-driven UI framework
|
|
3
|
+
A server-driven UI framework built around one promise: **an agent can build, test, and operate a complete app end-to-end — with no human in the loop and no browser anywhere in sight.** That falls out of a single idea: the interface is structured data, not code.
|
|
4
4
|
|
|
5
|
-
The server
|
|
5
|
+
The server is a pure transformer — current UI state + an action → next state + a fresh view tree (a JSON tree of typed nodes). A thin adapter renders it to the DOM (or, with the same wire, a terminal) with zero app-specific code; the browser never owns application state. Because the whole interface is data, three things come for free:
|
|
6
|
+
|
|
7
|
+
- **Build it blind** — there's nothing to *look at* to get it right; the view description is the truth.
|
|
8
|
+
- **Test it without a browser** — every interaction is a plain function from input → output, so the entire UI is exercised with ordinary in-process unit tests (no headless browser, no Playwright, no flake) on every CI run. The quiet giant: the full-coverage, test-everything-through-the-UI regime that's usually aspirational is the *default* here.
|
|
9
|
+
- **Use it like an API** — a finished app is already a clean, self-describing wire protocol, so a *different* agent can drive it (read the screen → act → read the next) as if it were an API, because it is one.
|
|
6
10
|
|
|
7
11
|
The frontend is backend-agnostic: it speaks a small JSON contract over a single POST endpoint that takes `multipart/form-data` (with `_action` and `_state` fields). A .NET reference backend ships in the repo, but any language can produce the same contract.
|
|
8
12
|
|
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
|
}
|
|
@@ -358,7 +359,7 @@ export class BrowserAdapter {
|
|
|
358
359
|
this.sectionKeyCounter.set(baseKey, ordinal + 1);
|
|
359
360
|
const finalKey = `${baseKey}:${ordinal}`;
|
|
360
361
|
const el = document.createElement("details");
|
|
361
|
-
el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}`;
|
|
362
|
+
el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
362
363
|
el.dataset.sectionKey = finalKey;
|
|
363
364
|
// Initial render is always closed — the post-render restore loop in
|
|
364
365
|
// render() re-applies `open=true` for keys the user had open before.
|
|
@@ -382,7 +383,7 @@ export class BrowserAdapter {
|
|
|
382
383
|
// action — see validateSectionAction in server.ts.
|
|
383
384
|
if (n.link) {
|
|
384
385
|
const a = document.createElement("a");
|
|
385
|
-
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}`;
|
|
386
|
+
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
386
387
|
a.href = n.link.url;
|
|
387
388
|
// Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
|
|
388
389
|
// byte-for-byte: target=_blank + rel=noopener noreferrer when external.
|
|
@@ -426,7 +427,7 @@ export class BrowserAdapter {
|
|
|
426
427
|
return;
|
|
427
428
|
}
|
|
428
429
|
const el = document.createElement("section");
|
|
429
|
-
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
430
|
+
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
430
431
|
if (n.heading) {
|
|
431
432
|
const h = document.createElement("h2");
|
|
432
433
|
h.className = "vms-section__heading";
|
|
@@ -510,7 +511,24 @@ export class BrowserAdapter {
|
|
|
510
511
|
ev.files = files;
|
|
511
512
|
on(ev);
|
|
512
513
|
};
|
|
513
|
-
|
|
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 &&
|
|
536
|
-
const 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;
|
|
@@ -104,6 +104,10 @@ export interface SectionNode {
|
|
|
104
104
|
limit?: 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
105
105
|
/** For `layout:"cards"`: overrides the auto-fit minimum track width (today's fixed `--vms-card-min: 16rem`) — a CLOSED size scale (NOT raw CSS, per P2) mapping xs→10rem, sm→13rem, md→16rem (= today's default), lg→20rem, xl→24rem. Emits .vms-cards-min--{token} which sets `--vms-card-min` on that element (the existing `repeat(auto-fit, minmax(min(var(--vms-card-min),100%),1fr))` cards rule reads it). Omitted = no class → the inherited 16rem default holds = byte-identical to today. Intended for `cards`; harmless elsewhere (it only sets a variable the cards rule reads). (GRID-01) */
|
|
106
106
|
minItem?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
107
|
+
/** Per-child cross-axis self-alignment — maps to CSS `align-self`, the per-child counterpart to the parent-level `align`. In the default vertical `stack` (a flex column) the cross axis is horizontal, so `start | center | end` = left/center/right, overriding the parent's alignment for THIS section only. The motivating case is a chat bubble (a `variant:"card"` section aligned to one side); general-purpose otherwise (a centered narrow group). Omitted = no class → the child inherits the parent's cross-axis alignment = byte-identical to today. Emits .vms-self--{value}. Closed union (CHILD-01). */
|
|
108
|
+
alignSelf?: "start" | "center" | "end";
|
|
109
|
+
/** Bounded content-width cap — a CLOSED token set (NOT raw CSS, per P2), implemented with `max-inline-size` (writing-mode-safe). Fractional values are proportional to the container (half→50%, two-thirds→66.6667%, three-quarters→75%) — the chat-gutter case, scaling with width; `prose` caps at the readable measure (min(65ch,100%), the Tailwind `max-w-prose` / Every-Layout `--measure` cap). The section still shrinks to its content below the cap. Omitted = no class → no cap (today's full-width behavior) = byte-identical. Emits .vms-maxw--{value}. Closed union (CHILD-02). */
|
|
110
|
+
maxWidth?: "half" | "two-thirds" | "three-quarters" | "prose";
|
|
107
111
|
/** Optional stable preservation key for the renderer's collapsible-section open-state snapshot. Used only when `collapsible: true`. Provide when `heading` isn't unique within a page or is absent — otherwise the renderer falls back to `heading ?? "vms-section-anon"`, disambiguated by per-render ordinal. Omitted = use the heading fallback. */
|
|
108
112
|
id?: string;
|
|
109
113
|
/** When true, the section renders as a native `<details>`/`<summary>` disclosure widget (closed by default). Aesthetic, client-side primitive — the open/closed state is DOM-local and the server does NOT round-trip it (same conceptual model as draft text values in unsubmitted form inputs). The browser adapter snapshots `<details>.open` before each re-render and restores it after, keyed by `id ?? heading ?? "vms-section-anon"` (disambiguated by per-render ordinal); a re-key drops the preserved state (the documented escape hatch for rare server-driven expansion). The summary label is the section's `heading`; a headingless collapsible section uses the fallback string `"Show details"`. If a section needs to start open, do not mark it collapsible. Omitted/false = today's `<section>` rendering, byte-identical. */
|
|
@@ -203,6 +207,15 @@ export interface FormNode {
|
|
|
203
207
|
* FieldNode.action still fires per-field). */
|
|
204
208
|
submitAction?: ActionEvent;
|
|
205
209
|
submitLabel?: string;
|
|
210
|
+
/** Full control of the submit button (#22). When set, the form renders THIS
|
|
211
|
+
* button as its submit — carrying its own `label` + `emphasis`/`tone`/`size`/
|
|
212
|
+
* `width`/`pendingLabel` — instead of synthesizing a plain one from
|
|
213
|
+
* `submitLabel`. The form fires `submitButton.action` on click, on native
|
|
214
|
+
* submit (Enter in a text field), and on textarea Enter (`submitOnEnter`).
|
|
215
|
+
* Mirrors the universal "write your own submit button" pattern (e.g. a
|
|
216
|
+
* full-width submit: `width:"full"`). Takes precedence over
|
|
217
|
+
* `submitLabel`/`submitAction`, which are ignored for the button when set. */
|
|
218
|
+
submitButton?: ButtonNode;
|
|
206
219
|
/** 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
220
|
layout?: "stack" | "inline";
|
|
208
221
|
/** Multi-action submit buttons (#15). Each is a full ButtonNode (so
|
|
@@ -261,6 +274,11 @@ export interface ButtonNode {
|
|
|
261
274
|
tone?: "danger" | "warning" | "success" | "info";
|
|
262
275
|
/** 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
276
|
size?: "sm" | "lg";
|
|
277
|
+
/** Width axis — `"full"` stretches the button to fill its container's cross
|
|
278
|
+
* axis (the standard full-width / "block" button: MUI `fullWidth`, Ant
|
|
279
|
+
* `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
|
|
280
|
+
* = content-width (the default hug). Orthogonal to emphasis/tone/size. */
|
|
281
|
+
width?: "auto" | "full";
|
|
264
282
|
/** Transient label shown from click until the dispatch resolves (response
|
|
265
283
|
* arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
|
|
266
284
|
* lifecycle pattern at a different beat: shown DURING the round-trip
|
|
@@ -431,6 +449,11 @@ export interface CopyButtonNode {
|
|
|
431
449
|
tone?: "danger" | "warning" | "success" | "info";
|
|
432
450
|
/** Box geometry — mirrors ButtonNode.size. Emits .vms-button--{size}. Omitted = md. Closed union. */
|
|
433
451
|
size?: "sm" | "lg";
|
|
452
|
+
/** Width axis — `"full"` stretches the button to fill its container's cross
|
|
453
|
+
* axis (the standard full-width / "block" button: MUI `fullWidth`, Ant
|
|
454
|
+
* `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
|
|
455
|
+
* = content-width (the default hug). Orthogonal to emphasis/tone/size. */
|
|
456
|
+
width?: "auto" | "full";
|
|
434
457
|
}
|
|
435
458
|
/**
|
|
436
459
|
* The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
|
|
@@ -463,6 +486,14 @@ export interface CopyButtonNode {
|
|
|
463
486
|
* meaningful. For list/detail and similar text panes use `split` / `sidebar`,
|
|
464
487
|
* which collapse to a single column intrinsically on their own (zero @media).
|
|
465
488
|
*/
|
|
489
|
+
/** A thematic break / separator (#22) — the standard divider primitive (MUI/Ant
|
|
490
|
+
* `<Divider>`, Radix `<Separator>`). Horizontal (default) renders an `<hr>` with
|
|
491
|
+
* its implicit `role="separator"`; vertical renders a `role="separator"` div with
|
|
492
|
+
* `aria-orientation="vertical"` for row layouts. No content. */
|
|
493
|
+
export interface DividerNode {
|
|
494
|
+
type: "divider";
|
|
495
|
+
orientation?: "horizontal" | "vertical";
|
|
496
|
+
}
|
|
466
497
|
export interface FitsNode {
|
|
467
498
|
type: "fits";
|
|
468
499
|
/** 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
|
|
3
|
+
"version": "3.2.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",
|
package/styles/default.css
CHANGED
|
@@ -278,6 +278,24 @@ body {
|
|
|
278
278
|
.vms-align--stretch { align-items: stretch; }
|
|
279
279
|
.vms-align--baseline { align-items: baseline; }
|
|
280
280
|
|
|
281
|
+
/* ── Child-side modifiers: alignSelf (per-child cross axis → align-self) + maxWidth
|
|
282
|
+
(bounded content cap → max-inline-size) on a SectionNode (CHILD-01/02 / 3.2.0).
|
|
283
|
+
alignSelf is the per-child counterpart to align: in the default flex column stack
|
|
284
|
+
the cross axis is horizontal, so start/center/end = left/center/right and it
|
|
285
|
+
overrides the parent's alignment for ONE section — the chat-bubble case (a card
|
|
286
|
+
pinned to one side). maxWidth is a CLOSED token set (P2), not raw CSS: fractional
|
|
287
|
+
values scale with the container (the chat gutter), prose caps at the readable
|
|
288
|
+
measure min(65ch,100%) (the established min(X,100%) intrinsic-safety idiom, same
|
|
289
|
+
as the cards rule). Both intrinsic, zero @media (P1). An OMITTED field emits no
|
|
290
|
+
class → inherit parent alignment / no cap = byte-identical to today. ── */
|
|
291
|
+
.vms-self--start { align-self: flex-start; }
|
|
292
|
+
.vms-self--center { align-self: center; }
|
|
293
|
+
.vms-self--end { align-self: flex-end; }
|
|
294
|
+
.vms-maxw--half { max-inline-size: 50%; }
|
|
295
|
+
.vms-maxw--two-thirds { max-inline-size: 66.6667%; }
|
|
296
|
+
.vms-maxw--three-quarters { max-inline-size: 75%; }
|
|
297
|
+
.vms-maxw--prose { max-inline-size: min(65ch, 100%); }
|
|
298
|
+
|
|
281
299
|
/* ── Layout preset: switcher (SWITCH-01/02 / 1.13.0) — the Every-Layout Switcher,
|
|
282
300
|
zero @media. N equal items flip all-row ↔ all-stack ATOMICALLY via a negative
|
|
283
301
|
`flex-basis`: above the threshold the basis `(threshold - 100%) * 999` goes
|
|
@@ -569,6 +587,15 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
569
587
|
control line lives on the base rule + --vms-control-line). */
|
|
570
588
|
.vms-button--sm { padding: var(--vms-space-2xs) var(--vms-space-sm); font-size: var(--vms-text-sm); line-height: 1.125rem; }
|
|
571
589
|
.vms-button--lg { padding: var(--vms-space-sm) var(--vms-space-lg); font-size: var(--vms-text-lg); line-height: 1.75rem; }
|
|
590
|
+
/* width — the standard full-width / "block" button (#22). Stretches to fill the
|
|
591
|
+
container's cross axis (overriding the base align-self:flex-start), so a form
|
|
592
|
+
submit lines up with a column of width:100% inputs. */
|
|
593
|
+
.vms-button--full { align-self: stretch; width: 100%; }
|
|
594
|
+
|
|
595
|
+
/* ── Divider / separator (#22) — the standard thematic break. Horizontal is an
|
|
596
|
+
<hr>; vertical is a role="separator" div for row layouts. ── */
|
|
597
|
+
.vms-divider { border: none; border-top: 1px solid var(--vms-border); width: 100%; margin-block: var(--vms-space-sm); }
|
|
598
|
+
.vms-divider--vertical { border-top: none; border-left: 1px solid var(--vms-border); width: 0; align-self: stretch; margin: 0 var(--vms-space-sm); }
|
|
572
599
|
|
|
573
600
|
/* ── Pending state (0.8.0 / issue #11) ──
|
|
574
601
|
Applied by BrowserAdapter when a ButtonNode with `pendingLabel` is clicked.
|