@ashley-shrok/viewmodel-shell 5.1.2 → 5.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/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 `file` inputs — a file
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
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": "5.1.2",
3
+ "version": "5.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 \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",
@@ -574,6 +574,231 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
574
574
  overflow: auto;
575
575
  }
576
576
 
577
+ /* Lookup / reference picker (FieldNode inputType="lookup" / "lookup-multiple",
578
+ Phase 21) — an editable combobox whose listbox popup floats over the page.
579
+ Built entirely from the existing .vms-field__* tokens: no new colors, no new
580
+ custom properties, so every theme recolors it automatically. */
581
+ .vms-field--lookup { position: relative; }
582
+ .vms-field__popup {
583
+ /* Anchored to .vms-field--lookup's positioning context, floating over what
584
+ follows so the popup never reflows the form when it opens. */
585
+ position: absolute;
586
+ top: 100%;
587
+ left: 0;
588
+ right: 0;
589
+ z-index: 20;
590
+ margin-top: var(--vms-space-2xs);
591
+ max-height: 16rem;
592
+ overflow-y: auto;
593
+ background: var(--vms-surface);
594
+ border: 1px solid var(--vms-border);
595
+ border-radius: var(--vms-radius);
596
+ /* A literal rgba, matching the toast overlay's shadow (the house precedent
597
+ for a floating surface) — there is no --vms-shadow token, and inventing
598
+ one would mean touching all 12 themes for a drop shadow. */
599
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24);
600
+ color-scheme: var(--vms-color-scheme);
601
+ }
602
+ /* The popup is rendered ALWAYS and hidden when closed (aria-controls must stay
603
+ valid while hidden). This rule is LOAD-BEARING: the UA's [hidden] default is
604
+ display:none, but the block above would otherwise win on specificity and the
605
+ popup would be permanently visible. */
606
+ .vms-field__popup[hidden] { display: none; }
607
+ /* The lookup's aria-live status regions and its assistive hint: available to
608
+ assistive tech, invisible on screen. Deliberately NOT display:none and NOT
609
+ visibility:hidden — both REMOVE the element from the accessibility tree, so a
610
+ live region styled that way is never announced and the hint is never read.
611
+ This is the standard visually-hidden clip; it must stay clipped-but-rendered. */
612
+ .vms-field__live {
613
+ position: absolute;
614
+ width: 1px;
615
+ height: 1px;
616
+ margin: -1px;
617
+ padding: 0;
618
+ border: 0;
619
+ overflow: hidden;
620
+ clip: rect(0 0 0 0);
621
+ clip-path: inset(50%);
622
+ white-space: nowrap;
623
+ }
624
+ .vms-field__option {
625
+ padding: var(--vms-space-xs) var(--vms-space-md);
626
+ font-size: var(--vms-text-md);
627
+ line-height: var(--vms-control-line);
628
+ color: var(--vms-text);
629
+ cursor: pointer;
630
+ /* §7 item 33 (reflow) — option text WRAPS rather than truncating: a
631
+ candidate is a person's or record's name, not a status word, and at high
632
+ magnification a truncated option is unreadable. The border delimits a
633
+ wrapped option so it never reads as two separate suggestions (GOV.UK's 12x
634
+ tester: "the words just looked like separate suggestions that didn't make
635
+ much sense"). */
636
+ overflow-wrap: anywhere;
637
+ border-bottom: 1px solid var(--vms-border);
638
+ }
639
+ .vms-field__option:last-child { border-bottom: none; }
640
+ .vms-field__option:hover { background: var(--vms-surface-2); }
641
+ /* The active (aria-activedescendant) option. DOM focus stays in the input, so
642
+ :focus cannot express this — the highlight must be driven off aria-selected,
643
+ which is the same fact the live region announces. */
644
+ .vms-field__option[aria-selected="true"] {
645
+ background: var(--vms-accent-glow);
646
+ color: var(--vms-text);
647
+ }
648
+
649
+ /* ── lookup chips (Phase 21, LOOK-03; BOTH modes since 21-14 / D2a) ──
650
+ The selection, rendered OUTSIDE the combobox as chip(s) — for `lookup` AND
651
+ `lookup-multiple`, from ONE implementation. The only difference between the
652
+ two is arity (single replaces on pick, multi appends), which lives in
653
+ browser.ts and has no expression here at all: a chip is a chip.
654
+
655
+ 🚨 We diverge from SLDS deliberately and it is worth knowing why, because the
656
+ survey says the opposite: Salesforce renders single-select's selection INSIDE
657
+ the input and ships no pill element for single at all. 21-13 followed them,
658
+ and the operator found that model has NOWHERE TO CLICK TO TYPE — the pill was
659
+ the input, so clicking in just appended to the name. Their model is coherent
660
+ given clear-then-search; ours is coherent given always-typeable. (There is no
661
+ `.vms-field--lookup-selected` any more; do not resurrect it.)
662
+
663
+ Structure is role=list/listitem + a real <button> (design §7 item 24) — see
664
+ browser.ts. This is presentation only; nothing here is on the wire.
665
+
666
+ ⚠️ CONTRAST: the chip's fg/bg pair is NOT covered by the fixed 13-pair
667
+ check:aa-contrast gate, so it is hand-measured across the shipped default +
668
+ all 12 themes: 10.63:1 worst (dark-*), 13.60:1 (light-*), vs a 4.5:1 text bar.
669
+ Single-select reuses this exact pair rather than inventing a token, so it adds
670
+ no new pair to measure. Re-measure if --_chip-tone ever stops being
671
+ --vms-text. */
672
+ .vms-field__chips {
673
+ display: flex;
674
+ flex-wrap: wrap;
675
+ gap: var(--vms-space-2xs);
676
+ /* Reset the <ul>. NOTE this is exactly why browser.ts sets role="list"
677
+ EXPLICITLY: `list-style: none` strips the implicit list/listitem roles in
678
+ Safari/VoiceOver, so a styled <ul> silently stops being a list — the
679
+ accessibility regression is caused BY this line and repaired in the
680
+ renderer. Do not "clean up" that seemingly-redundant role. */
681
+ list-style: none;
682
+ margin: 0;
683
+ padding: 0;
684
+ }
685
+ .vms-field__chip {
686
+ /* The .vms-badge private-tone technique (default.css, .vms-badge): ONE private
687
+ custom property holds the working color and the fill, text and border all
688
+ derive from it, so EVERY THEME RECOLORS AUTOMATICALLY — --vms-text and
689
+ --vms-surface are theme-owned. Retoning a chip would be one line. */
690
+ --_chip-tone: var(--vms-text);
691
+ display: inline-flex;
692
+ align-items: center;
693
+ /* Hug content in ANY parent layout. Carried over from .vms-badge's comment
694
+ (written in 5.1.2 for exactly this failure mode) because the chip hits it
695
+ IDENTICALLY: a chip is semantically inline, but as a flex item it is
696
+ BLOCKIFIED (inline-flex → flex) and the chip row's align-items would then
697
+ stretch it. A definite cross-size opts out of that stretch —
698
+ align-items:stretch only stretches items whose cross size is `auto`.
699
+ Deliberately NOT align-self:start, which would top-anchor the chip since a
700
+ child's align-self beats the parent's align-items. */
701
+ width: fit-content;
702
+ max-width: 100%;
703
+ gap: var(--vms-space-2xs);
704
+ font-size: var(--vms-text-xs);
705
+ font-weight: 600;
706
+ /* Not the badge's `1`: a chip's text WRAPS (below), and line-height:1 makes
707
+ wrapped lines collide. */
708
+ line-height: 1.35;
709
+ /* Tighter on the right — the remove button carries its own hit area. */
710
+ padding: 0.2em 0.3em 0.2em 0.6em;
711
+ border-radius: 999px;
712
+ /* 🚨 DELIBERATELY NOT `white-space: nowrap` — the ONE thing not copied from
713
+ .vms-badge, and the easiest to reintroduce by "consistency". Design §7 item
714
+ 33: chip text must WRAP WITHOUT TRUNCATION. A badge is a short status word;
715
+ a chip holds a PERSON'S NAME. GOV.UK's 12x-magnification tester on the
716
+ truncation failure: "Some larger country names may take over two lines. It
717
+ wasn't always clear to him that this had happened — the words just looked
718
+ like separate suggestions that didn't make much sense." The pill border-
719
+ radius + the surface knockout are what delimit a wrapped chip so it never
720
+ reads as two. (The popup's options carry the same rule and their own
721
+ delimiting border — see .vms-field__option.) */
722
+ overflow-wrap: anywhere;
723
+ /* The polarity-adaptive knockout — the house technique, used 18x in this file.
724
+ Mixing TOWARD --vms-surface (never toward white/black) auto-adapts to light
725
+ AND dark themes with ZERO per-theme deepening.
726
+ ⚠️ PROVISIONAL MIX RATIO — 12%, not the badge's 16%, and Plan 21-09's
727
+ hand-check owns the final number. Two reasons it must not simply inherit
728
+ 16%: (1) the fg/bg pair here is a NEW one the fixed 13-pair check:aa-contrast
729
+ gate does NOT auto-cover; (2) the badge's 16% was tuned for a small badge on
730
+ a PAGE surface. This is deliberately conservative — the tone is --vms-text
731
+ (near-maximal contrast against a 12% tint of ITSELF in the surface), not the
732
+ badge's --vms-text-muted, because a chip carries CONTENT (a name) rather
733
+ than muted status. The chip's background is opaque, so the field's input
734
+ backdrop does not enter the contrast pair. */
735
+ background: color-mix(in srgb, var(--_chip-tone) 12%, var(--vms-surface));
736
+ color: var(--_chip-tone);
737
+ border: 1px solid transparent;
738
+ }
739
+ /* §7 item 31 — the two-step Backspace's ARMED chip: the first press highlights
740
+ the last chip rather than deleting it. This is the VISUAL half only; the
741
+ announcement is the half that matters (browser.ts), because an arm that were
742
+ only a CSS class would leave an AT user pressing Backspace, hearing nothing,
743
+ pressing again, and deleting something they were never told about. Border, not
744
+ a fill swap, so it cannot disturb the contrast pair above. */
745
+ .vms-field__chip--armed { border-color: var(--_chip-tone); }
746
+ .vms-field__chip-remove {
747
+ display: inline-flex;
748
+ align-items: center;
749
+ justify-content: center;
750
+ /* Never let the button shrink or wrap away from its label — the chip's text
751
+ wraps; its remove affordance must not. */
752
+ flex: none;
753
+ align-self: flex-start;
754
+ width: 1.4em;
755
+ height: 1.4em;
756
+ padding: 0;
757
+ border: 0;
758
+ border-radius: 999px;
759
+ background: transparent;
760
+ /* Inherit the chip's tone rather than re-deriving it, so a retoned chip keeps
761
+ a consistent remove button for free. */
762
+ color: inherit;
763
+ font-family: inherit;
764
+ font-size: 1.1em;
765
+ line-height: 1;
766
+ cursor: pointer;
767
+ }
768
+ .vms-field__chip-remove:hover {
769
+ background: color-mix(in srgb, var(--_chip-tone) 22%, transparent);
770
+ }
771
+ /* The chip is INTERACTIVE where the badge is inert, so its remove button needs a
772
+ visible focus ring: it is a REAL focusable button (§7 item 26 gives the chips a
773
+ roving tabindex precisely so DOM focus lands here), and it is reachable by
774
+ keyboard alone. :focus-visible, so a mouse click doesn't ring it.
775
+
776
+ 🚨 DELIBERATELY --_chip-tone, NOT --vms-accent (which is what
777
+ .vms-field__input:focus uses, and what this rule used before Plan 21-09's
778
+ hand-check). Measured, not assumed: --vms-accent clears the 3:1 non-text bar
779
+ (SC 1.4.11) against --vms-surface on all 13 targets, but only BARELY on
780
+ light-amber/light-green/light-teal (3.34 / 3.23 / 3.28). The chip's fill is a
781
+ TINTED surface, and that tint eats the entire remaining margin — the accent
782
+ ring measures 2.63 / 2.54 / 2.58 against the chip fill on those three themes:
783
+ a FAILING focus indicator on a keyboard-only affordance.
784
+
785
+ The mix ratio is NOT the lever, which is the non-obvious part: the ring still
786
+ fails at a 4% tint and only reaches 3.10:1 at 2% — a chip with no visible fill
787
+ at all. No chip worth rendering can rescue the accent here.
788
+
789
+ --_chip-tone is the structural fix and needs no per-theme rule: it is the same
790
+ polarity-adaptive knockout pair as the chip's TEXT (mixing toward
791
+ --vms-surface), so it inherits that pair's measured headroom — 10.63:1 worst
792
+ (dark-*), 13.60:1 (light-*), vs a 3:1 bar. It is also what the chip's own
793
+ private-tone technique already does everywhere else: .vms-field__chip--armed
794
+ borders with it and this button's `color` inherits it. The accent ring was the
795
+ outlier, not the convention. Re-measure if --_chip-tone ever stops being
796
+ --vms-text. */
797
+ .vms-field__chip-remove:focus-visible {
798
+ outline: 2px solid var(--_chip-tone);
799
+ outline-offset: 1px;
800
+ }
801
+
577
802
  /* Form-collected checkbox (FieldNode inputType="checkbox") — inline label. */
578
803
  .vms-field--checkbox { flex-direction: row; align-items: center; gap: var(--vms-space-xs); }
579
804
  .vms-field--checkbox .vms-field__input {