@ashley-shrok/viewmodel-shell 5.1.2 → 6.0.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/agent-skill.md +69 -0
- package/dist/browser.d.ts +42 -0
- package/dist/browser.js +1532 -3
- package/dist/index.d.ts +285 -5
- package/dist/server.js +6 -0
- package/dist/tui.js +41 -0
- package/package.json +3 -1
- package/styles/default.css +334 -1
package/dist/index.d.ts
CHANGED
|
@@ -281,18 +281,62 @@ export interface FormNode {
|
|
|
281
281
|
submitOnEnter?: boolean;
|
|
282
282
|
children: ViewNode[];
|
|
283
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Phase 21 (LOOK-01) — one reference in a `lookup` / `lookup-multiple` field:
|
|
286
|
+
* an id, optionally the label to show for it, optionally what KIND of thing it
|
|
287
|
+
* is. Used by BOTH `FieldNode.selected` (what is currently chosen) and
|
|
288
|
+
* `FieldNode.candidates` (the current search results) — the same shape in both
|
|
289
|
+
* places, which is what lets a picked value and an invented one stay
|
|
290
|
+
* homogeneous (see `allowCustom`).
|
|
291
|
+
*
|
|
292
|
+
* `value` is the id and the only thing that ever round-trips. It is typed as a
|
|
293
|
+
* `string` deliberately — not `string | number` — so the wire stays
|
|
294
|
+
* byte-identical across backends (the same rationale as `min`/`max`/`step`
|
|
295
|
+
* above): a numeric id serialized by System.Text.Json and by `JSON.stringify`
|
|
296
|
+
* must produce the same bytes, and a union invites exactly that drift.
|
|
297
|
+
*
|
|
298
|
+
* `label` is OPTIONAL and MUST be **omitted when it equals `value`** — an
|
|
299
|
+
* option not set is simply absent, and a label that merely repeats the id
|
|
300
|
+
* carries no information. This is exactly the free-form-tag case: a tag is a
|
|
301
|
+
* value whose label is itself, so the label is absent. (Salesforce models the
|
|
302
|
+
* same rule: `displayValue` is null for a plain string field and populated
|
|
303
|
+
* only when it carries something `value` doesn't — a localization, a format,
|
|
304
|
+
* or a related record's name.)
|
|
305
|
+
*
|
|
306
|
+
* `type` is the polymorphic-reference tag, and it exists because an id alone is
|
|
307
|
+
* not an identity. Microsoft, on Dataverse's `_ownerid_value`, verbatim: the
|
|
308
|
+
* GUID *"doesn't tell you whether the owner of the record is a user or a
|
|
309
|
+
* team."* Set it when one field can reference more than one kind of record;
|
|
310
|
+
* **omit it for monomorphic references**, where it would say nothing the field
|
|
311
|
+
* doesn't already say.
|
|
312
|
+
*/
|
|
313
|
+
export interface LookupItem {
|
|
314
|
+
/** The id. The only half that round-trips. */
|
|
315
|
+
value: string;
|
|
316
|
+
/** Display text for `value`. Omitted = the label equals the value. */
|
|
317
|
+
label?: string;
|
|
318
|
+
/** What KIND of record `value` names. Omitted = a monomorphic reference. */
|
|
319
|
+
type?: string;
|
|
320
|
+
}
|
|
284
321
|
export interface FieldNode {
|
|
285
322
|
type: "field";
|
|
286
323
|
name: string;
|
|
287
|
-
inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
|
|
324
|
+
inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "lookup" | "lookup-multiple" | "code";
|
|
288
325
|
/** Path into state where this input reads its current value and writes user
|
|
289
326
|
* changes (e.g. `fields.title`). REQUIRED for value-bearing inputs
|
|
290
327
|
* (text/email/password/number/date/time/datetime-local/textarea/select/
|
|
291
|
-
* select-multiple/checkbox/code) and OPTIONAL for
|
|
328
|
+
* select-multiple/checkbox/lookup/lookup-multiple/code) and OPTIONAL for
|
|
329
|
+
* `file` inputs — a file
|
|
292
330
|
* input's binary rides the multipart side channel (fileRegistry keyed on
|
|
293
331
|
* `name`), so omit `bind` on a file input to avoid writing a
|
|
294
332
|
* `{filename,size}` placeholder object into state (which breaks a
|
|
295
|
-
* string/string-map state slot on round-trip).
|
|
333
|
+
* string/string-map state slot on round-trip).
|
|
334
|
+
*
|
|
335
|
+
* For the lookup inputTypes this path holds the ID and NOTHING ELSE:
|
|
336
|
+
* `lookup` binds a `string` (one id), `lookup-multiple` binds a `string[]`
|
|
337
|
+
* (the ids). The human-readable label never lives here — it travels on
|
|
338
|
+
* `selected`, server→client only (see the `selected` doc). The id is state;
|
|
339
|
+
* the label is view. */
|
|
296
340
|
bind?: string;
|
|
297
341
|
label?: string;
|
|
298
342
|
placeholder?: string;
|
|
@@ -310,7 +354,27 @@ export interface FieldNode {
|
|
|
310
354
|
* `.vms-field--error`, the control's `aria-invalid="true"`, and wires the
|
|
311
355
|
* message into `aria-describedby`. The view-side complement to the
|
|
312
356
|
* response-level `rejected` channel — set it on the offending field when you
|
|
313
|
-
* build the tree. Omitted = no error shown.
|
|
357
|
+
* build the tree. Omitted = no error shown.
|
|
358
|
+
*
|
|
359
|
+
* **Lookup note (5.2.0, OPEN-5): a SEARCH failure reuses this slot** — if the
|
|
360
|
+
* directory query behind a `lookup` / `lookup-multiple` fails, put the message
|
|
361
|
+
* here. Do NOT swallow it: react-select actively discards fetch errors
|
|
362
|
+
* (`loader.then(callback, () => callback())`), which makes a dead backend
|
|
363
|
+
* indistinguishable from "no results" — a direct violation of principle 8
|
|
364
|
+
* (nothing important fails quietly), and no surveyed library has a
|
|
365
|
+
* search-error state at all. Reusing `error` closes that gap at zero wire
|
|
366
|
+
* cost, and §7 item 9 reserves the assertive channel for errors, so a genuine
|
|
367
|
+
* search failure is a correct fit for this slot's `role="alert"`.
|
|
368
|
+
*
|
|
369
|
+
* ⚠️ **The known, accepted wart:** this overloads the slot. "The server is
|
|
370
|
+
* down" is NOT "your input is invalid", so the `aria-invalid="true"` it sets
|
|
371
|
+
* on the combobox is semantically wrong for a search failure; and one slot
|
|
372
|
+
* with two meanings COLLIDES if a field has a live search failure and a
|
|
373
|
+
* pending validation error at once (last writer wins). Accepted for v1: the
|
|
374
|
+
* app owns which message it puts here, the collision needs both conditions
|
|
375
|
+
* simultaneously on the same field, and a distinct search-error slot is
|
|
376
|
+
* purely additive later. Recorded rather than hidden, so a future phase can
|
|
377
|
+
* promote it with the reasoning already written down. */
|
|
314
378
|
error?: string;
|
|
315
379
|
/** Hint/help text rendered below the control as `.vms-field__help` and wired
|
|
316
380
|
* into the control's `aria-describedby`. Omitted = no hint. */
|
|
@@ -330,6 +394,198 @@ export interface FieldNode {
|
|
|
330
394
|
value: string;
|
|
331
395
|
label: string;
|
|
332
396
|
}>;
|
|
397
|
+
/**
|
|
398
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-01). What is currently chosen, resolved
|
|
399
|
+
* to display text by the server.
|
|
400
|
+
*
|
|
401
|
+
* 🚨 **THE LOAD-BEARING INVARIANT: `selected` is VIEW, not STATE.** It travels
|
|
402
|
+
* **server→client ONLY**, is recomputed on every render, and is **NEVER
|
|
403
|
+
* authoritative and NEVER trusted coming back from the client.** `bind` holds
|
|
404
|
+
* the id and is the only authoritative thing. **Direction is the entire safety
|
|
405
|
+
* argument** — a client cannot forge a label into a handler because a client
|
|
406
|
+
* never sends one. Why, from our own principles: the view is a pure function
|
|
407
|
+
* of state; the id persists and round-trips (state), while the label is
|
|
408
|
+
* derived, server-owned, and recomputed every render (view). Putting the label
|
|
409
|
+
* in the bind is putting view into state, and it manufactures a
|
|
410
|
+
* cache-invalidation problem that every mature enterprise reference field
|
|
411
|
+
* designed away by storing the id alone — rename a user and every label on
|
|
412
|
+
* every referencing record changes with zero writes.
|
|
413
|
+
*
|
|
414
|
+
* 🚨 **`selected` and `candidates` are SEPARATE FIELDS ON PURPOSE, and the
|
|
415
|
+
* selected label is NEVER resolved from `candidates`.** Fusing them is the
|
|
416
|
+
* original sin. With an id-valued field, *"filter the candidate list"* and
|
|
417
|
+
* *"forget what's selected"* are **the same operation** — so a picker that
|
|
418
|
+
* resolves its label out of the candidate list renders a raw database id the
|
|
419
|
+
* moment a form loads with a value already set and no search has occurred
|
|
420
|
+
* (the cold-start case, which is the case that matters most). Ant Design ships
|
|
421
|
+
* this failure silently (`label: ... ?? item.value`); Zag chased it across
|
|
422
|
+
* four changelog entries and two years. Read the label from `selected` and
|
|
423
|
+
* only from `selected`.
|
|
424
|
+
*
|
|
425
|
+
* **Always an array**, including for single `lookup`, where it holds 0 or 1
|
|
426
|
+
* entries. This is deliberate and there is no precedent for it elsewhere in
|
|
427
|
+
* this file, so the reasoning lives here: a `T | T[]` union drifts across
|
|
428
|
+
* backends (it does not serialize byte-identically under both
|
|
429
|
+
* System.Text.Json and `JSON.stringify`), and the banked parity lesson is to
|
|
430
|
+
* prefer the shape that cannot drift over the shape that reads nicer.
|
|
431
|
+
*
|
|
432
|
+
* `selected[].value` duplicating `bind` is **accepted, deliberate
|
|
433
|
+
* redundancy** — keying by value is robust; positional parallel arrays are
|
|
434
|
+
* not.
|
|
435
|
+
*
|
|
436
|
+
* Omitted = nothing currently selected.
|
|
437
|
+
*/
|
|
438
|
+
selected?: LookupItem[];
|
|
439
|
+
/**
|
|
440
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-01). The current search results — what
|
|
441
|
+
* the popup listbox offers. Feeds the popup and **nothing else**. **NEVER the
|
|
442
|
+
* source of a selected label** (see `selected`).
|
|
443
|
+
*
|
|
444
|
+
* 🚨 **ORDER IS MEANINGFUL APP DATA. The renderer presents candidates AS
|
|
445
|
+
* GIVEN — it sorts nothing, dedupes nothing, and truncates nothing.** The app
|
|
446
|
+
* decides what comes back and in what order; **relevance ordering is the
|
|
447
|
+
* SERVER's judgment, never the widget's.** This is universal in mature
|
|
448
|
+
* pickers: Salesforce's picker `searchType` **defaults to `Recent`**, and
|
|
449
|
+
* Dynamics shows the five most-recently-used rows plus five favourites,
|
|
450
|
+
* explicitly NOT filtered by the search term. A renderer that "helpfully"
|
|
451
|
+
* alphabetized for tidiness would **silently destroy a server-side ranking
|
|
452
|
+
* with no way for the app to stop it** — a real consumer sorts candidates by
|
|
453
|
+
* recency-weighted mention frequency in their own provider handler, and that
|
|
454
|
+
* ranking is the whole product. (Scope: this governs the PRESENTATION of
|
|
455
|
+
* `candidates`. It is not a ban on the renderer having logic — deduping
|
|
456
|
+
* `bind` on commit in `lookup-multiple` is a state write about the user's own
|
|
457
|
+
* accumulated selection, and is correct.)
|
|
458
|
+
*
|
|
459
|
+
* 🚨 **Any cap MUST be VISIBLE in the tree. Nothing truncates silently.**
|
|
460
|
+
* There is no wire field for a cap: the app renders a `TextNode` saying so —
|
|
461
|
+
* *"Refine your filter — N matches, max is X"*, the canonical table-workflow
|
|
462
|
+
* pattern. The anti-pattern is ServiceNow's 15-result cap applied post-ACL
|
|
463
|
+
* behind a hard 250-row SQL ceiling, where **an exact-match record can be
|
|
464
|
+
* silently invisible** in a large table. A cap the user cannot see is a
|
|
465
|
+
* correctness bug wearing a performance knob's clothes.
|
|
466
|
+
*
|
|
467
|
+
* 🚨 **The picker's filter is UX, NEVER authorization.** Narrowing what is
|
|
468
|
+
* *offered* is not a security boundary, and a filter that looks like one is
|
|
469
|
+
* precisely what gets trusted by mistake. ServiceNow says it outright: *"To
|
|
470
|
+
* restrict what data specific users can access, use ACLs not reference
|
|
471
|
+
* qualifiers."* Salesforce runs two separate layers for exactly this reason —
|
|
472
|
+
* a metadata `lookupFilter` enforced server-side on save, versus the
|
|
473
|
+
* component's UI-only `filter`. **The server authorizes in the action
|
|
474
|
+
* handler, with the real auth context, exactly as every other VMS action
|
|
475
|
+
* does.** Omitting a record from `candidates` hides it from the dropdown and
|
|
476
|
+
* from nothing else — a client that already knows an id can still put it in
|
|
477
|
+
* `bind`, so the handler is the only thing standing between a user and a
|
|
478
|
+
* record they may not touch.
|
|
479
|
+
*
|
|
480
|
+
* Omitted = no results to offer.
|
|
481
|
+
*/
|
|
482
|
+
candidates?: LookupItem[];
|
|
483
|
+
/**
|
|
484
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-04). Path into state where the typed
|
|
485
|
+
* query lives, so the server can see it and the view stays a pure function of
|
|
486
|
+
* state. Separate from `bind`, which holds the id — the query and the
|
|
487
|
+
* selection are different facts and never share a slot.
|
|
488
|
+
*
|
|
489
|
+
* Required for a working search: with a `searchAction` but no `searchBind`,
|
|
490
|
+
* the query is dispatched but the server can never read what was typed — a
|
|
491
|
+
* silently dead search that renders perfectly and returns nothing forever. The
|
|
492
|
+
* browser warns `[vms:lookup-no-searchbind]`.
|
|
493
|
+
*
|
|
494
|
+
* Keystrokes write here immediately (the query is state); **Enter** dispatches
|
|
495
|
+
* `searchAction`. That is the same cadence `TableNode.filterAction` uses.
|
|
496
|
+
*
|
|
497
|
+
* 🚨 The query is what the user TYPED. It is **not** the display text: an
|
|
498
|
+
* input showing the selected label (a form loaded with a reference already
|
|
499
|
+
* set) holds a label, not a query, and the renderer does not flush it here.
|
|
500
|
+
* Clearing the box clears the query and reveals the label again — clearing the
|
|
501
|
+
* SEARCH TEXT is not clearing the SELECTION (only `bind` holds that).
|
|
502
|
+
*
|
|
503
|
+
* Omitted = the query is not round-tripped.
|
|
504
|
+
*/
|
|
505
|
+
searchBind?: string;
|
|
506
|
+
/**
|
|
507
|
+
* LOOKUP INPUTS ONLY (Phase 21). Dispatched **on ENTER**, as an **ordinary
|
|
508
|
+
* action** — the same cadence `TableNode.filterAction` uses, and the same one
|
|
509
|
+
* `action` above uses. Keystrokes write `searchBind` and dispatch nothing;
|
|
510
|
+
* there is **no debounce** and **no live-query lane**.
|
|
511
|
+
*
|
|
512
|
+
* 🚨 **`blocking` means exactly what it means everywhere else, and the
|
|
513
|
+
* framework NEVER sets it.** Your `ActionEvent` is dispatched as you declared
|
|
514
|
+
* it — omit `blocking` (the default, blocking/serialized lane) unless you have
|
|
515
|
+
* a specific reason not to.
|
|
516
|
+
*
|
|
517
|
+
* **Leaving it blocking is the recommended default, and it is a correctness
|
|
518
|
+
* property, not a preference:** a blocking action is serialized by the shell's
|
|
519
|
+
* dispatch guard (a second action cannot dispatch while a round trip is in
|
|
520
|
+
* flight), so a stale search response can never land after — and clobber — a
|
|
521
|
+
* newer action. Opting into `blocking: false` means *this response may be
|
|
522
|
+
* discarded, may arrive out of order, and may coexist with another in flight*;
|
|
523
|
+
* that is yours to choose, and yours to handle.
|
|
524
|
+
*
|
|
525
|
+
* 🚨 **Do NOT combine with {@link FieldNode.allowCustom} — it is UNSUPPORTED
|
|
526
|
+
* in v1 and warns `[vms:lookup-ambiguous-enter]`.** One Enter cannot both
|
|
527
|
+
* invent a value and run a search. The two supported shapes are: this field
|
|
528
|
+
* WITHOUT `allowCustom` (a directory/reference picker — Enter searches,
|
|
529
|
+
* arrow+Enter accepts a candidate), or `allowCustom` WITHOUT `searchAction`
|
|
530
|
+
* (a free-form tags field — Enter invents). Declaring both is ignored in
|
|
531
|
+
* favour of the search, loudly.
|
|
532
|
+
*
|
|
533
|
+
* ⚠️ **`searchAction` OCCUPIES Enter, so {@link FieldNode.action} is
|
|
534
|
+
* unreachable on a lookup that declares one.** This is a deliberate
|
|
535
|
+
* limitation, not a bug: Enter is this control's only dispatch key and the
|
|
536
|
+
* search owns it — there is no second Enter to give `action`, and inventing a
|
|
537
|
+
* second submit gesture is a keybinding no combobox pattern sanctions. **On a
|
|
538
|
+
* searching lookup, put the submit on a `ButtonNode`.**
|
|
539
|
+
*
|
|
540
|
+
* **There is NO minimum-character gate**, deliberately. **An EMPTY query is a
|
|
541
|
+
* legitimate query and IS dispatched**, so an app may answer it with
|
|
542
|
+
* most-recently-used candidates rather than nothing (Salesforce's picker
|
|
543
|
+
* `searchType` defaults to `Recent`).
|
|
544
|
+
*
|
|
545
|
+
* Omitted = no search; the field is a plain id input.
|
|
546
|
+
*/
|
|
547
|
+
searchAction?: ActionEvent;
|
|
548
|
+
/**
|
|
549
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-06). The **declared** custom-entry axis:
|
|
550
|
+
* may the user commit a value that isn't one of the offered candidates?
|
|
551
|
+
*
|
|
552
|
+
* **Never inferred from behavior.** *Choosing somebody to mention* is very
|
|
553
|
+
* different from *inventing a new tag* — different ACTS sharing one widget —
|
|
554
|
+
* so the control DECLARES which it is doing rather than leaving it to be
|
|
555
|
+
* guessed from what the user typed.
|
|
556
|
+
*
|
|
557
|
+
* An invented value stays a homogeneous {@link LookupItem}, **never a bare
|
|
558
|
+
* string**, so no `LookupItem | string` union ever arises. MUI's `multiple +
|
|
559
|
+
* freeSolo` yields exactly that heterogeneous union — forcing every consumer
|
|
560
|
+
* to branch on `typeof`, and their own docs warn it *"may cause type
|
|
561
|
+
* mismatch."* We never admit a bare string, so it cannot happen here. A
|
|
562
|
+
* free-form tag is simply a value whose label equals itself (and is therefore
|
|
563
|
+
* omitted — see {@link LookupItem}).
|
|
564
|
+
*
|
|
565
|
+
* ⇒ `allowCustom: true` + no `candidates` + labels omitted **is a free-form
|
|
566
|
+
* tags input, with NO special case in the renderer.** This supersedes the
|
|
567
|
+
* separately-designed `inputType: "tags"` proposal.
|
|
568
|
+
*
|
|
569
|
+
* 🚨 **Do NOT combine with {@link FieldNode.searchAction} — it is UNSUPPORTED
|
|
570
|
+
* in v1 and warns `[vms:lookup-ambiguous-enter]`.** Type "urgent", press
|
|
571
|
+
* Enter: invent the tag, or search for it? Both are legitimate readings of the
|
|
572
|
+
* same keystroke, and no precedence serves both (invent-first starves the
|
|
573
|
+
* search; search-first starves invention forever) — that there is no good
|
|
574
|
+
* ordering is the tell that the shape is wrong, so v1 does not guess.
|
|
575
|
+
* Suggestions on a tags field are deferred, exactly as the parked `tags`
|
|
576
|
+
* design already deferred them. Declaring both ignores `allowCustom`.
|
|
577
|
+
*
|
|
578
|
+
* Whether a given value was picked or invented is **server-decidable** — the
|
|
579
|
+
* server produced every candidate it ever offered, so it can test the id
|
|
580
|
+
* against its own id space. There is deliberately no wire marker for
|
|
581
|
+
* provenance (no `__isNew__`): react-select needs one because it is
|
|
582
|
+
* client-only and has no server to ask; we have a server. The explicitness
|
|
583
|
+
* this decision demands is satisfied by `allowCustom` being a declared axis on
|
|
584
|
+
* the node — the app declares the act — not by a per-value flag.
|
|
585
|
+
*
|
|
586
|
+
* Omitted = false (custom entries rejected; only offered candidates commit).
|
|
587
|
+
*/
|
|
588
|
+
allowCustom?: boolean;
|
|
333
589
|
/** Optional language hint for `inputType: "code"`. Emitted as a class
|
|
334
590
|
* (`vms-field--code-{language}`) so apps can attach a syntax-highlighter
|
|
335
591
|
* library (CodeMirror, Monaco, etc.) — the framework only ships
|
|
@@ -466,7 +722,21 @@ export interface StatBarNode {
|
|
|
466
722
|
type: "stat-bar";
|
|
467
723
|
stats: Array<{
|
|
468
724
|
label: string;
|
|
469
|
-
value
|
|
725
|
+
/** The stat's value. Typed `string` (not `string | number`) so the TS and
|
|
726
|
+
* .NET backends emit BYTE-IDENTICAL wire — a bare number would serialize as
|
|
727
|
+
* JSON `12` in TS but the .NET twin's `string Value` can only emit `"12"`,
|
|
728
|
+
* a real cross-backend drift the parity suite never exercised. Format the
|
|
729
|
+
* number server-side (`String(n)`, `n.toFixed(2)`, `$${n}`) — the value is
|
|
730
|
+
* display text, and any formatting an app wants is richer than a bare
|
|
731
|
+
* number anyway. (Narrowed in 6.0.0 — see CHANGELOG; the field was unused.) */
|
|
732
|
+
value: string;
|
|
733
|
+
/** Optional semantic status tone for this tile — the universal status color
|
|
734
|
+
* axis (same closed set as Section/Button/ListItem/TableRow). Renders the
|
|
735
|
+
* tile as a subtly tinted chip (surface tint + colored border, reusing the
|
|
736
|
+
* --vms-error/-warning/-success/-info tokens), so an unhealthy stat reads at
|
|
737
|
+
* a glance rather than via one small line of text. Omitted = today's neutral
|
|
738
|
+
* inline stat. Closed union. */
|
|
739
|
+
tone?: "danger" | "warning" | "success" | "info";
|
|
470
740
|
}>;
|
|
471
741
|
}
|
|
472
742
|
export interface TabsNode {
|
|
@@ -737,6 +1007,16 @@ export interface StepItem {
|
|
|
737
1007
|
label: string;
|
|
738
1008
|
/** Optional one-line supporting text shown beside/under the label. */
|
|
739
1009
|
description?: string;
|
|
1010
|
+
/** Optional semantic status tone for this step — the universal status color
|
|
1011
|
+
* axis (same closed set as Section/Button/ListItem/TableRow). ORTHOGONAL to
|
|
1012
|
+
* the done/current/upcoming state the framework DERIVES from `current`: tone
|
|
1013
|
+
* overlays a semantic color onto the marker (e.g. a failed/blocked stage read
|
|
1014
|
+
* as `danger`, a stage needing attention as `warning`) regardless of its
|
|
1015
|
+
* position in the sequence. Omitted = the derived state's default appearance
|
|
1016
|
+
* (accent for done/current, muted for upcoming). Because tone is app-authored
|
|
1017
|
+
* status reinforced by the step's own label text, it is NOT color-only (same
|
|
1018
|
+
* posture as Section tone). Closed union. */
|
|
1019
|
+
tone?: "danger" | "warning" | "success" | "info";
|
|
740
1020
|
}
|
|
741
1021
|
/** A discrete step / stepper / wizard progress indicator — an ordered list of
|
|
742
1022
|
* stages with a single 0-based `current` index. Per-step status DERIVES from
|
package/dist/server.js
CHANGED
|
@@ -107,6 +107,12 @@ function collectActions(node, enclosingForm, out) {
|
|
|
107
107
|
const field = node;
|
|
108
108
|
if (field.action)
|
|
109
109
|
recordAction(field.action, enclosingForm, out);
|
|
110
|
+
// Phase 21 (LOOK-01) — a lookup's debounced search is a real dispatch to a
|
|
111
|
+
// real handler, so its name participates in uniqueness like every other
|
|
112
|
+
// dispatch site. `action` and `searchAction` are independent: a lookup may
|
|
113
|
+
// carry both (Enter commits, typing searches).
|
|
114
|
+
if (field.searchAction)
|
|
115
|
+
recordAction(field.searchAction, enclosingForm, out);
|
|
110
116
|
return;
|
|
111
117
|
}
|
|
112
118
|
case "checkbox": {
|
package/dist/tui.js
CHANGED
|
@@ -1254,6 +1254,26 @@ function FormView({ node, ctx }) {
|
|
|
1254
1254
|
// B3 ships single-select; select-multiple semantics
|
|
1255
1255
|
// are deferred (no native OpenTUI multi-select
|
|
1256
1256
|
// widget; would need a custom focusable list — B5).
|
|
1257
|
+
// lookup / lookup-multiple → <text label> + <input value=the ID at `bind`> +
|
|
1258
|
+
// a <text> line listing the `selected` labels
|
|
1259
|
+
// (label ?? value, per the wire's "label omitted
|
|
1260
|
+
// = label equals value" rule). Both inputTypes get
|
|
1261
|
+
// the SAME layout; lookup-multiple joins its
|
|
1262
|
+
// selected labels with ", ".
|
|
1263
|
+
// 🚨 The debounced `searchAction`, the popup
|
|
1264
|
+
// listbox, the chips, the roving tabindex, and the
|
|
1265
|
+
// aria-live region are DELIBERATELY NOT ported.
|
|
1266
|
+
// This is the ARCHITECTURE WORKING AS DESIGNED,
|
|
1267
|
+
// not a gap: `bind` holds the id and nothing else,
|
|
1268
|
+
// so — per the lookup design of record §6 — "an
|
|
1269
|
+
// agent sets `bind` to the id and never touches the
|
|
1270
|
+
// search UI. It does not need to know the label."
|
|
1271
|
+
// A terminal user does exactly what an agent does.
|
|
1272
|
+
// Everything omitted here is a browser/DOM concept
|
|
1273
|
+
// with no OpenTUI analog (the a11y spec is a
|
|
1274
|
+
// screen-reader contract that means nothing in a
|
|
1275
|
+
// terminal). Same honest-degradation precedent as
|
|
1276
|
+
// select-multiple → single-select above.
|
|
1257
1277
|
// checkbox → <text "[x] label" or "[ ] label"> — decorative
|
|
1258
1278
|
// for B3 (toggle interactivity in B5).
|
|
1259
1279
|
// file → <text "{label}: [file: …]"> placeholder —
|
|
@@ -1327,6 +1347,27 @@ function FieldView({ node, ctx }) {
|
|
|
1327
1347
|
handleSubmit(next);
|
|
1328
1348
|
} }, `${node.name}::wire::${wireValue}`)] }));
|
|
1329
1349
|
}
|
|
1350
|
+
// ── lookup / lookup-multiple: honest degradation to a bound id input ───
|
|
1351
|
+
// See the per-inputType layout contract in the header comment above for WHY
|
|
1352
|
+
// this is a complete implementation for the terminal rather than a stub: the
|
|
1353
|
+
// id is the only thing that round-trips, and typing it directly is precisely
|
|
1354
|
+
// the agent-drivable path the design of record §6 describes. No search
|
|
1355
|
+
// machinery is ported (no debounce, no searchAction dispatch, no popup, no
|
|
1356
|
+
// chips, no live region) — those are DOM concepts, and the a11y baseline they
|
|
1357
|
+
// implement is a screen-reader contract with no terminal meaning.
|
|
1358
|
+
if (node.inputType === "lookup" || node.inputType === "lookup-multiple") {
|
|
1359
|
+
// Read the display text from `selected` and ONLY from `selected` — never
|
|
1360
|
+
// from `candidates`. That is the load-bearing invariant of the whole
|
|
1361
|
+
// primitive (the label is view, server-owned, recomputed every render;
|
|
1362
|
+
// `candidates` is the search result set and says nothing about what is
|
|
1363
|
+
// currently chosen). `label ?? value` implements the wire rule that a label
|
|
1364
|
+
// equal to its value is omitted — a free-form tag shows as its own id.
|
|
1365
|
+
const selectedLabels = (node.selected ?? []).map((s) => s.label ?? s.value);
|
|
1366
|
+
const selectedLine = selectedLabels.length > 0 ? selectedLabels.join(", ") : "(none selected)";
|
|
1367
|
+
return (_jsxs("box", { flexDirection: "column", gap: 0, children: [_jsx("text", { fg: "#888888", children: label }), _jsx("input", { value: currentValue, placeholder: node.placeholder ?? "", focused: focused, onInput: (v) => ctx.setFieldValue(node.name, v), onSubmit: (v) => {
|
|
1368
|
+
handleSubmit(typeof v === "string" ? v : undefined);
|
|
1369
|
+
} }, `${node.name}::wire::${wireValue}`), _jsxs("text", { fg: "#888888", children: ["Selected: ", selectedLine] })] }));
|
|
1370
|
+
}
|
|
1330
1371
|
// ── checkbox: decorative glyph for B3 (toggle wiring is B5) ───────────
|
|
1331
1372
|
if (node.inputType === "checkbox") {
|
|
1332
1373
|
// Wire contract: checkbox-typed field's value is "true" | "false".
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.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 \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc -b tsconfig.tui.json",
|
|
33
33
|
"check:core-globals": "node scripts/check-core-platform-globals.mjs",
|
|
34
|
+
"check:test-types": "tsc -p tsconfig.test.json --noEmit",
|
|
35
|
+
"check:demo-types": "node scripts/check-demo-types.mjs",
|
|
34
36
|
"check:aa-contrast": "node scripts/check-aa-contrast.mjs",
|
|
35
37
|
"check:no-demo-style": "node scripts/check-no-demo-style.mjs",
|
|
36
38
|
"check:theme-byte-identity": "node scripts/check-theme-byte-identity.mjs",
|