@ashley-shrok/viewmodel-shell 3.2.1 → 3.4.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 +1 -1
- package/dist/browser.d.ts +5 -0
- package/dist/browser.js +99 -4
- package/dist/index.d.ts +34 -1
- package/dist/index.js +15 -3
- package/dist/server.js +30 -0
- package/package.json +1 -1
- package/styles/default.css +30 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ const shell = new ViewModelShell({
|
|
|
33
33
|
shell.load();
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
If your backend is .NET:
|
|
36
|
+
If your backend is .NET: install the [`AshleyShrok.ViewModelShell`](https://www.nuget.org/packages/AshleyShrok.ViewModelShell) NuGet package (`dotnet add package AshleyShrok.ViewModelShell`). It ships the full backend record set under the `ViewModelShell` namespace (`ViewNode`, `PageNode`, `FormNode`, `ShellResponse<TState>`, `ActionPayload<TState>`, etc.) — version-aligned with this npm package on major.minor.
|
|
37
37
|
|
|
38
38
|
For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
|
|
39
39
|
|
package/dist/browser.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
60
60
|
* When `action` is set, it fires on Enter (text-like) or change (select) —
|
|
61
61
|
* the new value is already in state by that point. */
|
|
62
62
|
private field;
|
|
63
|
+
/** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
|
|
64
|
+
* render help + error text below it, wiring aria-describedby / aria-invalid.
|
|
65
|
+
* Runs on the main field path (the hidden + checkbox-FieldNode variants
|
|
66
|
+
* return before this). */
|
|
67
|
+
private decorateField;
|
|
63
68
|
/** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
|
|
64
69
|
* write to state then dispatch the action name (if any). */
|
|
65
70
|
private checkbox;
|
package/dist/browser.js
CHANGED
|
@@ -240,6 +240,18 @@ export class BrowserAdapter {
|
|
|
240
240
|
case "copy-button": return this.copyButton(n, parent);
|
|
241
241
|
case "divider": return this.divider(n, parent);
|
|
242
242
|
case "fits": return this.fits(n, parent, on);
|
|
243
|
+
default: {
|
|
244
|
+
// Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
|
|
245
|
+
// Runtime trees are server-controlled JSON, so an unknown/forward-version
|
|
246
|
+
// node type CAN reach here at runtime even though the union is
|
|
247
|
+
// exhaustive at compile time. We keep rendering the rest of the tree
|
|
248
|
+
// (forward-compatible, like unknown sideEffect types) but warn so the
|
|
249
|
+
// node doesn't just vanish without a trace.
|
|
250
|
+
const unknownType = n.type;
|
|
251
|
+
console.warn(`[viewmodel-shell] Unknown node type ${JSON.stringify(unknownType)} — ` +
|
|
252
|
+
`rendering nothing for it. The client may be older than the server's tree.`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
243
255
|
}
|
|
244
256
|
}
|
|
245
257
|
kids(nodes, parent, on) {
|
|
@@ -760,8 +772,61 @@ export class BrowserAdapter {
|
|
|
760
772
|
}
|
|
761
773
|
wrapper.appendChild(inp);
|
|
762
774
|
}
|
|
775
|
+
this.decorateField(wrapper, n);
|
|
763
776
|
parent.appendChild(wrapper);
|
|
764
777
|
}
|
|
778
|
+
/** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
|
|
779
|
+
* render help + error text below it, wiring aria-describedby / aria-invalid.
|
|
780
|
+
* Runs on the main field path (the hidden + checkbox-FieldNode variants
|
|
781
|
+
* return before this). */
|
|
782
|
+
decorateField(wrapper, n) {
|
|
783
|
+
const control = wrapper.querySelector(".vms-field__input");
|
|
784
|
+
if (n.disabled) {
|
|
785
|
+
if (control)
|
|
786
|
+
control.disabled = true;
|
|
787
|
+
wrapper.classList.add("vms-field--disabled");
|
|
788
|
+
}
|
|
789
|
+
if (n.readonly && control && "readOnly" in control) {
|
|
790
|
+
control.readOnly = true;
|
|
791
|
+
}
|
|
792
|
+
// Native input constraints — min/max/step on <input>, maxLength on
|
|
793
|
+
// <input>/<textarea>. Strings pass straight to the attribute.
|
|
794
|
+
if (control instanceof HTMLInputElement) {
|
|
795
|
+
if (n.min != null)
|
|
796
|
+
control.min = n.min;
|
|
797
|
+
if (n.max != null)
|
|
798
|
+
control.max = n.max;
|
|
799
|
+
if (n.step != null)
|
|
800
|
+
control.step = n.step;
|
|
801
|
+
}
|
|
802
|
+
if (n.maxLength != null &&
|
|
803
|
+
(control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) {
|
|
804
|
+
control.maxLength = n.maxLength;
|
|
805
|
+
}
|
|
806
|
+
const describedBy = [];
|
|
807
|
+
if (n.help != null && n.help !== "") {
|
|
808
|
+
const helpEl = document.createElement("div");
|
|
809
|
+
helpEl.className = "vms-field__help";
|
|
810
|
+
helpEl.id = `vms-${n.name}-help`;
|
|
811
|
+
helpEl.textContent = n.help;
|
|
812
|
+
wrapper.appendChild(helpEl);
|
|
813
|
+
describedBy.push(helpEl.id);
|
|
814
|
+
}
|
|
815
|
+
if (n.error != null && n.error !== "") {
|
|
816
|
+
wrapper.classList.add("vms-field--error");
|
|
817
|
+
const errEl = document.createElement("div");
|
|
818
|
+
errEl.className = "vms-field__error";
|
|
819
|
+
errEl.id = `vms-${n.name}-error`;
|
|
820
|
+
errEl.setAttribute("role", "alert");
|
|
821
|
+
errEl.textContent = n.error;
|
|
822
|
+
wrapper.appendChild(errEl);
|
|
823
|
+
describedBy.push(errEl.id);
|
|
824
|
+
control?.setAttribute("aria-invalid", "true");
|
|
825
|
+
}
|
|
826
|
+
if (control && describedBy.length > 0) {
|
|
827
|
+
control.setAttribute("aria-describedby", describedBy.join(" "));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
765
830
|
/** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
|
|
766
831
|
* write to state then dispatch the action name (if any). */
|
|
767
832
|
checkbox(n, parent, on) {
|
|
@@ -771,6 +836,11 @@ export class BrowserAdapter {
|
|
|
771
836
|
inp.type = "checkbox";
|
|
772
837
|
inp.className = "vms-checkbox__input";
|
|
773
838
|
inp.name = n.name;
|
|
839
|
+
// Stable id so keyboard focus survives a re-render (poll or action). The
|
|
840
|
+
// wrapping <label> gives the click/label association; the id is purely for
|
|
841
|
+
// focus restore. Namespaced distinctly from field() ids (vms-${name}) to
|
|
842
|
+
// avoid an id collision when a field and a standalone checkbox share a name.
|
|
843
|
+
inp.id = `vms-checkbox-${n.name}`;
|
|
774
844
|
inp.checked = Boolean(this.sa.read(n.bind));
|
|
775
845
|
const mark = document.createElement("span");
|
|
776
846
|
mark.className = "vms-checkbox__mark";
|
|
@@ -792,9 +862,16 @@ export class BrowserAdapter {
|
|
|
792
862
|
button(n, parent, on) {
|
|
793
863
|
const btn = document.createElement("button");
|
|
794
864
|
btn.type = "button";
|
|
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" : ""}`;
|
|
865
|
+
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" : ""}${n.disabled ? " vms-button--disabled" : ""}`;
|
|
796
866
|
btn.textContent = n.label;
|
|
867
|
+
if (n.disabled)
|
|
868
|
+
btn.disabled = true;
|
|
797
869
|
btn.addEventListener("click", () => {
|
|
870
|
+
// Forms-completeness (3.4.0): a disabled button never dispatches. (Native
|
|
871
|
+
// `disabled` already suppresses the click, but guard anyway in case the
|
|
872
|
+
// attribute was cleared out-of-band.)
|
|
873
|
+
if (n.disabled)
|
|
874
|
+
return;
|
|
798
875
|
// pendingLabel: instant client-side feedback. Swap text + add
|
|
799
876
|
// .vms-button--pending BEFORE handing off to the dispatcher. On
|
|
800
877
|
// success the next render replaces the button entirely. On dispatch
|
|
@@ -868,6 +945,9 @@ export class BrowserAdapter {
|
|
|
868
945
|
const btn = document.createElement("button");
|
|
869
946
|
btn.className = `vms-tabs__tab${tab.value === n.selected ? " vms-tabs__tab--active" : ""}`;
|
|
870
947
|
btn.textContent = tab.label;
|
|
948
|
+
// Stable id so focus survives the re-render a tab click triggers (and any
|
|
949
|
+
// poll re-render) — render()'s restore keys off id.
|
|
950
|
+
btn.id = `vms-tab-${n.bind}-${tab.value}`;
|
|
871
951
|
btn.setAttribute("role", "tab");
|
|
872
952
|
btn.setAttribute("aria-selected", String(tab.value === n.selected));
|
|
873
953
|
btn.addEventListener("click", () => {
|
|
@@ -887,16 +967,25 @@ export class BrowserAdapter {
|
|
|
887
967
|
cls += ` vms-image--${n.shape}`;
|
|
888
968
|
img.className = cls;
|
|
889
969
|
img.src = n.src;
|
|
890
|
-
|
|
891
|
-
|
|
970
|
+
// Always set alt: a present alt for meaningful images, an explicit empty
|
|
971
|
+
// string for decorative ones (alt="" tells assistive tech to skip it,
|
|
972
|
+
// whereas a missing alt may make it announce the src/URL).
|
|
973
|
+
img.alt = n.alt ?? "";
|
|
892
974
|
parent.appendChild(img);
|
|
893
975
|
}
|
|
894
976
|
progress(n, parent) {
|
|
895
977
|
const track = document.createElement("div");
|
|
896
978
|
track.className = "vms-progress";
|
|
979
|
+
// Clamp to 0–100 (the documented range): an out-of-range value would
|
|
980
|
+
// otherwise overflow the track or render a negative-width bar.
|
|
981
|
+
const value = Math.max(0, Math.min(100, n.value));
|
|
982
|
+
track.setAttribute("role", "progressbar");
|
|
983
|
+
track.setAttribute("aria-valuemin", "0");
|
|
984
|
+
track.setAttribute("aria-valuemax", "100");
|
|
985
|
+
track.setAttribute("aria-valuenow", String(value));
|
|
897
986
|
const bar = document.createElement("div");
|
|
898
987
|
bar.className = "vms-progress__bar";
|
|
899
|
-
bar.style.width = `${
|
|
988
|
+
bar.style.width = `${value}%`;
|
|
900
989
|
track.appendChild(bar);
|
|
901
990
|
parent.appendChild(track);
|
|
902
991
|
}
|
|
@@ -1018,6 +1107,12 @@ export class BrowserAdapter {
|
|
|
1018
1107
|
inp.type = "text";
|
|
1019
1108
|
inp.className = "vms-table__filter-input";
|
|
1020
1109
|
inp.dataset.col = col.key;
|
|
1110
|
+
// Stable id so render()'s focus+caret restore can re-find this input
|
|
1111
|
+
// after a re-render — critical because a silent poll can fire mid-
|
|
1112
|
+
// keystroke while the user is typing a filter (the canonical
|
|
1113
|
+
// workflow-table pattern). Without an id the value survives (it's
|
|
1114
|
+
// bound state) but focus/caret are lost on every poll tick.
|
|
1115
|
+
inp.id = `vms-tablefilter-${col.key}`;
|
|
1021
1116
|
const bindPath = n.filterBinds?.[col.key];
|
|
1022
1117
|
const bound = bindPath != null ? this.sa.read(bindPath) : undefined;
|
|
1023
1118
|
inp.value = bound != null
|
package/dist/index.d.ts
CHANGED
|
@@ -192,7 +192,7 @@ export interface ListNode {
|
|
|
192
192
|
export interface ListItemNode {
|
|
193
193
|
type: "list-item";
|
|
194
194
|
id?: string;
|
|
195
|
-
/** Row lifecycle/selection STATE (NOT severity — that's `tone`). A freeform, app-extensible token; the framework ships styling for `active` (selected), `done`, `disabled`, `high` (priority)
|
|
195
|
+
/** Row lifecycle/selection STATE (NOT severity — that's `tone`). A freeform, app-extensible token; the framework ships list-item styling for `active` (selected), `done`, `disabled`, and `high` (priority). Appended as a BEM modifier: vms-list-item--{state}. An unrecognized state renders an unstyled class (it still round-trips; just no shipped rule). Orthogonal to `tone` (a row can be `state:"active"` AND `tone:"danger"`). (TableRow additionally ships a `running` style; ListItem does not yet.) */
|
|
196
196
|
state?: string;
|
|
197
197
|
/** Semantic intent/severity — the universal status tone axis (closed). Emits .vms-list-item--{tone} (colored accent border, reusing the shared tokens). Omitted = neutral. */
|
|
198
198
|
tone?: "danger" | "warning" | "success" | "info";
|
|
@@ -241,6 +241,35 @@ export interface FieldNode {
|
|
|
241
241
|
label?: string;
|
|
242
242
|
placeholder?: string;
|
|
243
243
|
required?: boolean;
|
|
244
|
+
/** Disables the control (greys it out, blocks input, excludes it from form
|
|
245
|
+
* submission). Server-owned. Emits `.vms-field--disabled` on the wrapper +
|
|
246
|
+
* the native `disabled` attribute. Omitted = enabled. */
|
|
247
|
+
disabled?: boolean;
|
|
248
|
+
/** Read-only: the value is shown and submitted but the user can't edit it
|
|
249
|
+
* (distinct from `disabled`, which also greys out + excludes from submit).
|
|
250
|
+
* Text-like inputs + textarea only. Emits the native `readonly` attribute. */
|
|
251
|
+
readonly?: boolean;
|
|
252
|
+
/** Per-field validation error message, rendered inline below the control as
|
|
253
|
+
* `.vms-field__error` (role="alert"); also sets the wrapper's
|
|
254
|
+
* `.vms-field--error`, the control's `aria-invalid="true"`, and wires the
|
|
255
|
+
* message into `aria-describedby`. The view-side complement to the
|
|
256
|
+
* response-level `rejected` channel — set it on the offending field when you
|
|
257
|
+
* build the tree. Omitted = no error shown. */
|
|
258
|
+
error?: string;
|
|
259
|
+
/** Hint/help text rendered below the control as `.vms-field__help` and wired
|
|
260
|
+
* into the control's `aria-describedby`. Omitted = no hint. */
|
|
261
|
+
help?: string;
|
|
262
|
+
/** `min`/`max`/`step` passed through to the native input attribute for
|
|
263
|
+
* `number`/`range`/date-time inputs. STRINGS (HTML-attribute semantics): a
|
|
264
|
+
* numeric bound (`"0"`), a date bound (`"2020-01-01"`), and `step` also
|
|
265
|
+
* accepts `"any"`. Typed as strings (not numbers) so the wire stays
|
|
266
|
+
* byte-identical across backends. Omitted = no constraint. */
|
|
267
|
+
min?: string;
|
|
268
|
+
max?: string;
|
|
269
|
+
step?: string;
|
|
270
|
+
/** Maximum input length (characters) for text-like inputs + textarea →
|
|
271
|
+
* native `maxlength`. Integer. Omitted = no cap. */
|
|
272
|
+
maxLength?: number;
|
|
244
273
|
options?: Array<{
|
|
245
274
|
value: string;
|
|
246
275
|
label: string;
|
|
@@ -279,6 +308,10 @@ export interface ButtonNode {
|
|
|
279
308
|
* `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
|
|
280
309
|
* = content-width (the default hug). Orthogonal to emphasis/tone/size. */
|
|
281
310
|
width?: "auto" | "full";
|
|
311
|
+
/** Disables the button — greys it out (`.vms-button--disabled` + native
|
|
312
|
+
* `disabled`) and the renderer will NOT dispatch its action on click.
|
|
313
|
+
* Server-owned (e.g. a submit gated on a precondition). Omitted = enabled. */
|
|
314
|
+
disabled?: boolean;
|
|
282
315
|
/** Transient label shown from click until the dispatch resolves (response
|
|
283
316
|
* arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
|
|
284
317
|
* lifecycle pattern at a different beat: shown DURING the round-trip
|
package/dist/index.js
CHANGED
|
@@ -311,9 +311,21 @@ export class ViewModelShell {
|
|
|
311
311
|
}
|
|
312
312
|
return;
|
|
313
313
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
// C2 (3.3.0) — a non-redirect response MAY legitimately omit `vm` (e.g. a
|
|
315
|
+
// side-effects-only or poll-keepalive response: "persist to storage and
|
|
316
|
+
// keep polling, but don't rebuild the view"). Do NOT blank the screen by
|
|
317
|
+
// rendering `undefined` — keep the current view, update state only if the
|
|
318
|
+
// server sent fresh state, and still schedule the next poll. Render only
|
|
319
|
+
// when a fresh tree actually arrived.
|
|
320
|
+
if (body.vm != null) {
|
|
321
|
+
this.currentVm = body.vm;
|
|
322
|
+
if (body.state !== undefined)
|
|
323
|
+
this.currentState = body.state;
|
|
324
|
+
this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
|
|
325
|
+
}
|
|
326
|
+
else if (body.state !== undefined) {
|
|
327
|
+
this.currentState = body.state;
|
|
328
|
+
}
|
|
317
329
|
this.schedulePoll(body.nextPollIn);
|
|
318
330
|
}
|
|
319
331
|
schedulePoll(nextPollIn) {
|
package/dist/server.js
CHANGED
|
@@ -180,6 +180,16 @@ function collectActions(node, enclosingForm, out) {
|
|
|
180
180
|
}
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
|
+
case "fits": {
|
|
184
|
+
// FitsNode.children are full ViewNode[] (can hold forms, buttons,
|
|
185
|
+
// sections with action/link) — the renderer picks ONE at runtime but
|
|
186
|
+
// every candidate ships on the wire, so all must be validated. Without
|
|
187
|
+
// this arm the entire fits subtree skipped action-name uniqueness checks.
|
|
188
|
+
const fits = node;
|
|
189
|
+
for (const child of fits.children)
|
|
190
|
+
collectActions(child, enclosingForm, out);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
183
193
|
// Nodes with no dispatch-bearing actions of their own:
|
|
184
194
|
// text, link, image, stat-bar, progress, copy-button
|
|
185
195
|
default:
|
|
@@ -328,6 +338,14 @@ function walkForSectionAction(node, outerInteractive) {
|
|
|
328
338
|
}
|
|
329
339
|
return;
|
|
330
340
|
}
|
|
341
|
+
case "fits": {
|
|
342
|
+
// A fits candidate can itself be a section with action/link (or contain
|
|
343
|
+
// one), so the nested-section-interaction rules must descend here too.
|
|
344
|
+
const fits = node;
|
|
345
|
+
for (const child of fits.children)
|
|
346
|
+
walkForSectionAction(child, outerInteractive);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
331
349
|
// Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
|
|
332
350
|
// tabs, progress, table, copy-button) carry no SectionNode descendants —
|
|
333
351
|
// TableNode rows hold strings + per-row controls, not sections.
|
|
@@ -369,6 +387,18 @@ export function parseJsonAction(body) {
|
|
|
369
387
|
if (typeof parsed.name !== "string" || parsed.name === "") {
|
|
370
388
|
throw new Error("Missing required 'name' field in action payload");
|
|
371
389
|
}
|
|
390
|
+
// C4 (3.3.0) — require `state`. The shell always sends it, but a hand-rolled
|
|
391
|
+
// curl/agent caller that posts `{name}` only would otherwise run the handler
|
|
392
|
+
// with `undefined` state and crash on the first property access → a 500
|
|
393
|
+
// uncaught_exception, the wrong error class. A missing/null state is a
|
|
394
|
+
// malformed request the caller can fix, so surface it as a 400 parse_error
|
|
395
|
+
// (this throw is caught by createAction's parse arm). An EMPTY object `{}` is
|
|
396
|
+
// a valid state and is left alone.
|
|
397
|
+
if (parsed.state == null) {
|
|
398
|
+
throw new Error("Missing required 'state' field in action payload. The action wire is " +
|
|
399
|
+
"{name, state} — echo back the state from the GET response (or the prior " +
|
|
400
|
+
"action response); send {} only if the app's state really is empty.");
|
|
401
|
+
}
|
|
372
402
|
return {
|
|
373
403
|
name: parsed.name,
|
|
374
404
|
state: parsed.state,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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
|
@@ -296,7 +296,7 @@ body {
|
|
|
296
296
|
.vms-maxw--three-quarters { max-inline-size: 75%; }
|
|
297
297
|
.vms-maxw--prose { max-inline-size: min(65ch, 100%); }
|
|
298
298
|
|
|
299
|
-
/* ── Layout preset: switcher (SWITCH-01/02 / 1.
|
|
299
|
+
/* ── Layout preset: switcher (SWITCH-01/02 / npm 1.12.0) — the Every-Layout Switcher,
|
|
300
300
|
zero @media. N equal items flip all-row ↔ all-stack ATOMICALLY via a negative
|
|
301
301
|
`flex-basis`: above the threshold the basis `(threshold - 100%) * 999` goes
|
|
302
302
|
hugely negative → clamped to 0 → all children share one row; below it the
|
|
@@ -494,6 +494,27 @@ body {
|
|
|
494
494
|
textarea.vms-field__input { min-height: 80px; }
|
|
495
495
|
select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl); }
|
|
496
496
|
|
|
497
|
+
/* Forms-completeness (3.4.0) — help/error text + disabled/readonly/error states.
|
|
498
|
+
help is the muted hint; error is the danger message (role="alert"). */
|
|
499
|
+
.vms-field__help { font-size: var(--vms-text-sm); color: var(--vms-text-muted); }
|
|
500
|
+
.vms-field__error { font-size: var(--vms-text-sm); color: var(--vms-error); }
|
|
501
|
+
.vms-field--error .vms-field__input { border-color: var(--vms-error); }
|
|
502
|
+
.vms-field--error .vms-field__input:focus {
|
|
503
|
+
border-color: var(--vms-error);
|
|
504
|
+
box-shadow: 0 0 0 3px var(--vms-error-glow);
|
|
505
|
+
}
|
|
506
|
+
.vms-field__input:disabled,
|
|
507
|
+
.vms-field--disabled .vms-field__input {
|
|
508
|
+
opacity: 0.55;
|
|
509
|
+
cursor: not-allowed;
|
|
510
|
+
}
|
|
511
|
+
.vms-field--disabled .vms-field__label { opacity: 0.7; }
|
|
512
|
+
/* read-only: editable-looking but visibly inert background (not greyed like disabled). */
|
|
513
|
+
.vms-field__input:read-only:not([type="file"]) {
|
|
514
|
+
background: color-mix(in srgb, var(--vms-surface) 70%, var(--vms-border));
|
|
515
|
+
cursor: default;
|
|
516
|
+
}
|
|
517
|
+
|
|
497
518
|
/* Code editor field — monospaced, tab-aware, no spell-check chrome.
|
|
498
519
|
Apps add syntax highlighting via .vms-field--code-{language} hooks. */
|
|
499
520
|
.vms-field__input--code {
|
|
@@ -617,6 +638,14 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
617
638
|
pointer-events: none;
|
|
618
639
|
}
|
|
619
640
|
|
|
641
|
+
/* Forms-completeness (3.4.0) — server-declared disabled button. */
|
|
642
|
+
.vms-button:disabled,
|
|
643
|
+
.vms-button--disabled {
|
|
644
|
+
opacity: 0.5;
|
|
645
|
+
cursor: not-allowed;
|
|
646
|
+
pointer-events: none;
|
|
647
|
+
}
|
|
648
|
+
|
|
620
649
|
/* Inside a list item, a danger button is treated as the row's "X" affordance:
|
|
621
650
|
muted and hidden until the row is hovered, then visible and red on hover. */
|
|
622
651
|
.vms-list-item .vms-button--danger {
|