@ashley-shrok/viewmodel-shell 5.1.1 → 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/agent-skill.md CHANGED
@@ -145,6 +145,75 @@ It never appears in the `_action` POST payload you send. The request body shape
145
145
 
146
146
  This connects to the polling section above: the `{"name": "poll"}` dispatch this manual already documents is itself an instance of a non-blocking action — a poll always rides the non-blocking lane client-side — so nothing about how you send a poll dispatch changes either.
147
147
 
148
+ ## Lookup / reference fields (`inputType:"lookup"`, `"lookup-multiple"`)
149
+
150
+ A `FieldNode` whose `inputType` is `lookup` or `lookup-multiple` is a reference to a row in some server-side set too large to enumerate into the tree (a 5,000-person directory, an 80,000-row customer table). A `select` says *"here are all the values, pick one"*; a lookup says *"the values are a database table — describe which row you mean."*
151
+
152
+ **The picker's search is an ordinary action on this same public wire.** No special API, no private transport, no undocumented endpoint — you drive a lookup with the exact dispatch shape documented above. This is worth stating plainly because it is not the norm: surveyed platforms keep their reference picker's transport private and undocumented. Here it is just the wire.
153
+
154
+ ### The happy path: set `bind` to the id. You are done.
155
+
156
+ **`bind` is the ONLY authoritative thing.** For `lookup` it holds a `string` (one id); for `lookup-multiple` a `string[]` (the ids). To set a reference, write the id at the bound path in the state blob and dispatch — exactly as with any other input (see *The round-trip rule*).
157
+
158
+ 🚨 **You do not need to know the label, and you must not supply one.** The label is not state. It travels on `selected`, **server→client ONLY**, is recomputed on every render, and is **never trusted coming back from the client** — a client-supplied label is meaningless. Direction is the whole safety argument: you cannot forge a label into a handler because you never send one. An agent that assumes `selected` round-trips will write labels into state and be silently wrong.
159
+
160
+ If you already know the id, **skip the search entirely** — set `bind` and dispatch. This is the common agent case, and it is the whole reason the model suits agents: knowing the id but not the label is not a wrinkle here, it is the normal way in.
161
+
162
+ ### Reading one
163
+
164
+ ```json
165
+ { "type": "field", "name": "assignee", "inputType": "lookup",
166
+ "bind": "form.assigneeId",
167
+ "selected": [ { "value": "u-1", "label": "Sally Omer" } ],
168
+ "searchBind": "form.assigneeQuery",
169
+ "searchAction": { "name": "search-agents" },
170
+ "candidates": [
171
+ { "value": "u-7", "label": "Sam Ortiz" },
172
+ { "value": "u-1", "label": "Sally Omer" }
173
+ ] }
174
+ ```
175
+
176
+ | Field | Direction | Read it as |
177
+ |---|---|---|
178
+ | `bind` | **round-trips — STATE** | The id (`string`, or `string[]` for `lookup-multiple`). The authoritative value. |
179
+ | `selected` | **server→client — VIEW** | `Array<{ value, label?, type? }>` — the resolved label(s) for what is **currently chosen**. Always an array (0 or 1 entries for single `lookup`). **This is the only source of a selected label.** |
180
+ | `candidates` | **server→client — VIEW** | `Array<{ value, label?, type? }>` — the **current search results**, i.e. what the popup offers. **Never the source of a selected label.** |
181
+ | `searchBind` | **round-trips — STATE** | Where the typed query lives. Write your query here to search. |
182
+ | `searchAction` | — | The action to dispatch to run a search. |
183
+ | `allowCustom` | — | See below. Omitted = false. |
184
+
185
+ **Never conflate `selected` and `candidates`.** They are separate fields on purpose. Resolving a label out of `candidates` is the classic failure: with an id-valued field, *"filter the candidate list"* and *"forget what's selected"* are the same operation, so a form that loads with a value already set and no search having occurred has an empty `candidates` and would render a raw database id. Read the label from `selected` and only from `selected`. On an item, `label` is **omitted when it equals `value`** (an option not set is simply absent) — so a missing `label` means the label *is* the id, not that it's unknown. `type` is the polymorphic-reference tag (an id alone is not an identity); it is omitted for monomorphic references.
186
+
187
+ ### 🚨 Candidate order is meaningful. It is preserved exactly.
188
+
189
+ The renderer presents `candidates` in the order given — it **sorts nothing, dedupes nothing, truncates nothing**. This cuts both ways, and you need both directions:
190
+
191
+ - **Reading a lookup:** the order **IS the server's ranking** (relevance / recency / most-recently-used). **Position 0 is the server's best answer.** Do not re-sort before choosing — re-sorting discards the server's judgment. Real adopters rank by things you cannot reconstruct client-side (e.g. recency-weighted mention frequency, computed server-side per operator).
192
+ - **Authoring a provider handler:** the order **you return is the order the user sees.** Ranking is yours to decide and yours to get right; nothing downstream will fix it up for you.
193
+
194
+ ### Searching (optional — it is just an action)
195
+
196
+ Write your query into the state blob at `searchBind`, then POST `searchAction`'s `name` with that state, exactly like any other dispatch. Read `candidates` off the returned `vm`, pick the item you want, then write its `value` into `bind` and dispatch your real action.
197
+
198
+ ```json
199
+ { "name": "search-agents", "state": { "form": { "assigneeQuery": "sal" } } }
200
+ ```
201
+
202
+ - **An empty query is a legitimate query.** There is no minimum-length gate. An empty query may legitimately return most-recently-used candidates rather than nothing.
203
+ - **`searchAction` is an ordinary action, with no special cadence.** A browser user fires it by pressing **Enter** in the field (typing alone dispatches nothing — there is no debounce and no live-query behavior). For you this changes nothing: it is one POST like any other, whenever you decide to ask.
204
+ - **`blocking` on a `searchAction` means what it means everywhere else** — see *Non-blocking actions* above. It is a client-side hint that never rides the POST payload, so as a wire-driving agent you dispatch normally regardless of its value.
205
+
206
+ ### `allowCustom` — invented values are declared, never inferred
207
+
208
+ `allowCustom: true` means the field accepts a value that isn't one of the offered candidates; omitted/`false` means only offered candidates commit. Choosing an existing record and inventing a new tag are different acts, so the control **declares** which it is doing rather than leaving you to infer it from behavior.
209
+
210
+ An invented value is **just a value whose label equals itself** — there is no bare string and no union anywhere. `allowCustom: true` + no `candidates` + labels omitted **is** a free-form tags input, with no special case. So the shapes above are all you ever have to parse.
211
+
212
+ ### Two rules that will bite you if you assume otherwise
213
+
214
+ - 🚨 **Any cap is VISIBLE in the tree. Nothing truncates silently.** If a server caps its results, it says so in the tree (the app renders a `TextNode`, per the canonical *"Refine your filter — N matches, max is X"* pattern). You should never be handed a silently truncated list. The anti-pattern this exists to avoid is real: a surveyed platform applies a 15-result cap *post-authorization* behind a hard 250-row SQL ceiling, so in a large table an exact-match record can be **silently invisible**. If you see a cap message, narrow the query — do not conclude the record doesn't exist.
215
+ - 🚨 **The picker's filter is UX, never authorization.** What `candidates` offers is a search-scoping convenience, **not an access boundary** — do not infer authorization from it. The server authorizes in the action handler with the real auth context, exactly as every other VMS action does. A value absent from `candidates` is not thereby forbidden, and a value present in `candidates` is not thereby permitted.
216
+
148
217
  ## Files
149
218
 
150
219
  File uploads use the multipart form above. One form entry per file input, keyed by the input's `name` attribute (from the corresponding node's `name` field in the tree). The file's binary content is the entry's value. JSON-body dispatch cannot carry files; use multipart.
package/dist/browser.d.ts CHANGED
@@ -8,8 +8,12 @@ export declare class BrowserAdapter implements Adapter {
8
8
  private sectionKeyCounter;
9
9
  private fitsObservers;
10
10
  private chartInstances;
11
+ private liveRegions;
12
+ private lookupOutsideHandlers;
13
+ private lookupOpenSnapshot;
11
14
  private chartKeyCounter;
12
15
  private chartKeysSeen;
16
+ private lookupKeysSeen;
13
17
  constructor(container: HTMLElement);
14
18
  render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
15
19
  navigate(url: string): void;
@@ -132,6 +136,44 @@ export declare class BrowserAdapter implements Adapter {
132
136
  * render help + error text below it, wiring aria-describedby / aria-invalid.
133
137
  * Runs on the main field path (the hidden + checkbox-FieldNode variants
134
138
  * return before this). */
139
+ /**
140
+ * The lookup's two aria-live status regions for `key`, created ONCE and
141
+ * reused forever after (§7 items 8 + 12).
142
+ *
143
+ * 🚨 This is the `chartInstances` idiom, not a new mechanism: the nodes are
144
+ * DETACHED by render()'s innerHTML wipe, NOT destroyed, and field() re-appends
145
+ * these same objects on every render. That is the entire point — a screen
146
+ * reader's registration is held against the OBJECT, so a structurally
147
+ * identical replacement is a region it has never heard of, and announcements
148
+ * stop silently while the DOM still looks perfect.
149
+ *
150
+ * Created EMPTY, before any results exist (§7 item 8): creating an element and
151
+ * injecting its text in the same tick announces NOTHING, because there was no
152
+ * registered element to observe a change on.
153
+ */
154
+ private lookupLiveRegions;
155
+ /**
156
+ * Announce `message` in `key`'s live region, IMMEDIATELY (§7 items 11 + 12).
157
+ *
158
+ * 🚨 NO DEBOUNCE, DELIBERATELY (21-11). This used to wait ~1400ms — GOV.UK's
159
+ * `statusDebounceMillis` — and that timer had exactly one job: the lookup
160
+ * searched on a ~300ms type-as-you-go cadence, so the region faced a
161
+ * PER-KEYSTROKE FIREHOSE, and on Safari/VoiceOver "typing echo can otherwise
162
+ * interrupt announcement of the aria live content". `searchAction` now fires on
163
+ * ENTER: one Enter, one announcement. The firehose is gone, so the tamer goes
164
+ * with it — and keeping it would mean an AT user waits 1.4 seconds to hear the
165
+ * answer to a question they explicitly asked, which is the opposite of the
166
+ * item-11 goal ("an async combobox silent during the fetch leaves AT users
167
+ * with no signal").
168
+ *
169
+ * ⇒ Do not re-add a debounce here without first re-adding the cadence that
170
+ * justified it. There isn't one.
171
+ *
172
+ * Alternates the two regions (§7 item 12) and clears the other, so identical
173
+ * consecutive messages still register as a change and are re-announced —
174
+ * writing the same text into one region twice is not a change, and is silence.
175
+ */
176
+ private announceLookup;
135
177
  private decorateField;
136
178
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
137
179
  * write to state then dispatch the action name (if any). */