@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 CHANGED
@@ -1,8 +1,12 @@
1
1
  # @ashley-shrok/viewmodel-shell
2
2
 
3
- 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.
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 returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to the DOM (or, with the same wire, a terminal). The browser never owns application state every interaction dispatches a semantic action to a single POST endpoint and the server returns the next state plus a fresh view.
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
- 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;
@@ -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.2",
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",
@@ -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.