@ashley-shrok/viewmodel-shell 3.1.0 → 3.2.1
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.js +3 -3
- package/dist/index.d.ts +4 -0
- package/package.json +1 -1
- package/styles/default.css +26 -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.js
CHANGED
|
@@ -359,7 +359,7 @@ export class BrowserAdapter {
|
|
|
359
359
|
this.sectionKeyCounter.set(baseKey, ordinal + 1);
|
|
360
360
|
const finalKey = `${baseKey}:${ordinal}`;
|
|
361
361
|
const el = document.createElement("details");
|
|
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}` : ""}`;
|
|
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}` : ""}`;
|
|
363
363
|
el.dataset.sectionKey = finalKey;
|
|
364
364
|
// Initial render is always closed — the post-render restore loop in
|
|
365
365
|
// render() re-applies `open=true` for keys the user had open before.
|
|
@@ -383,7 +383,7 @@ export class BrowserAdapter {
|
|
|
383
383
|
// action — see validateSectionAction in server.ts.
|
|
384
384
|
if (n.link) {
|
|
385
385
|
const a = document.createElement("a");
|
|
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}` : ""}`;
|
|
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}` : ""}`;
|
|
387
387
|
a.href = n.link.url;
|
|
388
388
|
// Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
|
|
389
389
|
// byte-for-byte: target=_blank + rel=noopener noreferrer when external.
|
|
@@ -427,7 +427,7 @@ export class BrowserAdapter {
|
|
|
427
427
|
return;
|
|
428
428
|
}
|
|
429
429
|
const el = document.createElement("section");
|
|
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.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" : ""}`;
|
|
431
431
|
if (n.heading) {
|
|
432
432
|
const h = document.createElement("h2");
|
|
433
433
|
h.className = "vms-section__heading";
|
package/dist/index.d.ts
CHANGED
|
@@ -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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
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
|
|
@@ -421,6 +439,14 @@ body {
|
|
|
421
439
|
flex-wrap: wrap;
|
|
422
440
|
}
|
|
423
441
|
.vms-form--inline > .vms-field { flex: 1 1 12rem; }
|
|
442
|
+
/* #23 — inline forms bottom-align their row (align-items: flex-end), but the
|
|
443
|
+
base align-self: flex-start on the implicit submit button and the buttons[]
|
|
444
|
+
row escapes that (an item's align-self beats the parent's align-items), so
|
|
445
|
+
the submit would top-anchor while the fields hang at the bottom. Drop those
|
|
446
|
+
direct children back to auto so the parent's flex-end wins — the same
|
|
447
|
+
override the modal footer already applies to .vms-button. */
|
|
448
|
+
.vms-form--inline > .vms-button,
|
|
449
|
+
.vms-form--inline > .vms-form__buttons { align-self: auto; }
|
|
424
450
|
/* Multi-action submit buttons row (0.10.0 / #15) — groups the form's
|
|
425
451
|
buttons[] entries on one line, like a dialog action bar. align-self
|
|
426
452
|
flex-start keeps the row from stretching full-width in the stacked form. */
|