@polycode-projects/the-mechanical-code-talker 4.1.8 → 5.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.
@@ -56,6 +56,7 @@ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
56
56
  // elsewhere in the document can never silently retract it.
57
57
  const EDITABLE_OTHER_PREDICATES = [
58
58
  "rdf:type", "rdfs:subClassOf", "mgx:display-name",
59
+ "mgx:model", "mgx:rotation",
59
60
  "mgx:is-container", "mgx:is-predator", "mgx:is-origin",
60
61
  "mgx:dig-spawns", "mgx:den-spawns", "mgx:den-resident",
61
62
  "mgx:dig-reach", "mgx:dig-spawn-max", "mgx:den-chance-in", "mgx:den-resident-chance-in",
@@ -125,6 +126,8 @@ export function renderMudEditorText(rows, state) {
125
126
  if (p === "rdf:type") { push(s, p, o, `${cap(s)} ${typePhraseFor(o)} ${o}.`); continue; }
126
127
  if (p === "rdfs:subClassOf") { push(s, p, o, `${cap(s)} is a kind of ${o}.`); continue; }
127
128
  if (p === "mgx:display-name") { push(s, p, o, `${cap(s)} is shown as ${o}.`); continue; }
129
+ if (p === "mgx:model") { push(s, p, o, `${cap(s)} is modelled as ${o}.`); continue; }
130
+ if (p === "mgx:rotation") { push(s, p, o, `${cap(s)} is turned ${o} degrees.`); continue; }
128
131
  if (p === "mgx:is-container" && o === "true") { push(s, p, o, `${cap(s)} is a container.`); continue; }
129
132
  if (p === "mgx:is-predator" && o === "true") { push(s, p, o, `${cap(s)} hunts the other animals.`); continue; }
130
133
  if (p === "mgx:is-origin" && o === "true") { push(s, p, o, `${cap(s)} is where the burrow starts.`); continue; }
@@ -179,6 +182,14 @@ const LINE_PATTERNS = [
179
182
  build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:hasMass", object: m[2] }) },
180
183
  { re: /^(.+?)\s+is\s+shown\s+as\s+(.+?)\.?$/i,
181
184
  build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:display-name", object: LOW(m[2]) }) },
185
+ // The two facts a 3D view needs and no other sentence in this table can
186
+ // say: which mesh draws a thing, and how far round it is turned. Kept
187
+ // apart from "is shown as" (a plain reading name, which every surface uses)
188
+ // because a renderer asking for the mesh must never be handed the name.
189
+ { re: /^(.+?)\s+is\s+modelled\s+as\s+(.+?)\.?$/i,
190
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:model", object: LOW(m[2]) }) },
191
+ { re: /^(.+?)\s+is\s+turned\s+(-?[\d.]+)\s+degrees?\.?$/i,
192
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:rotation", object: m[2] }) },
182
193
  { re: /^(.+?)\s+loses\s+([\d.]+)\s+mass\s+a\s+turn\.?$/i,
183
194
  build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:mass-drain-per-turn", object: m[2] }) },
184
195
  // The dig knobs. "at most N things" is tried before the plain "turns up X" so
@@ -306,3 +317,45 @@ export function planMudEditorSync(rows, state, triples) {
306
317
  }
307
318
  return { toAppend, toRemoveIds };
308
319
  }
320
+
321
+ /** The additive half of planMudEditorSync, for ONE already-parsed triple: the
322
+ * rows a single taught sentence implies, and never a retraction. A whole
323
+ * document says what the world contains, so a fact missing from it has gone;
324
+ * one sentence only ever says what it says, so nothing it leaves out is
325
+ * evidence of anything. Re-asserting a fact the world already holds appends
326
+ * nothing, and `reason` says which of the two happened. Pure. */
327
+ export function planTaughtMudTriple(rows, state, triple) {
328
+ if (!triple?.subject || !triple?.object) return { toAppend: [], reason: "nothing parsed" };
329
+ if (triple.kind === PLACEMENT_KIND) {
330
+ const current = state?.placements?.get(triple.subject);
331
+ if (current && current.predicate === triple.predicate && current.object === triple.object) {
332
+ return { toAppend: [], reason: `${triple.subject} is already ${triple.predicate} ${triple.object}` };
333
+ }
334
+ return {
335
+ toAppend: [triple],
336
+ reason: current
337
+ ? `${triple.subject} moves from ${current.object} to ${triple.object}`
338
+ : `${triple.subject} is placed ${triple.predicate} ${triple.object}`,
339
+ };
340
+ }
341
+ if (triple.kind === OPENNESS_KIND) {
342
+ const current = state?.openness?.get(triple.subject);
343
+ const wantOpen = triple.object === "true";
344
+ if (current && current.open === wantOpen) {
345
+ return { toAppend: [], reason: `${triple.subject} is already ${wantOpen ? "open" : "closed"}` };
346
+ }
347
+ return { toAppend: [triple], reason: `${triple.subject} becomes ${wantOpen ? "open" : "closed"}` };
348
+ }
349
+ if (triple.kind === MASS_KIND) {
350
+ const current = state?.masses?.get(triple.subject);
351
+ if (current && Number(current.value) === Number(triple.object)) {
352
+ return { toAppend: [], reason: `${triple.subject} already weighs ${triple.object}` };
353
+ }
354
+ return { toAppend: [triple], reason: `${triple.subject} weighs ${triple.object}` };
355
+ }
356
+ const key = tripleKey(triple);
357
+ if (editableMudOtherRows(rows).some((r) => tripleKey(r) === key)) {
358
+ return { toAppend: [], reason: `the world already says ${key}` };
359
+ }
360
+ return { toAppend: [triple], reason: `the world gains ${key}` };
361
+ }
@@ -0,0 +1,495 @@
1
+ // pill-complete.mjs — typeahead completion over a page's own live pill set,
2
+ // shared by every page that shows grounded-command pills (adventure.html,
3
+ // mud.html, and pages not yet built). Typing "u" in the command input
4
+ // completes to "unlock cabinet" because "unlock cabinet" is a pill on
5
+ // screen right now — never because the word "unlock" looks pluasible.
6
+ //
7
+ // THE HARD CONSTRAINT: a completion the page cannot ground is a product bug,
8
+ // not a UX rough edge (tmct's whole promise is the honest miss — a query it
9
+ // cannot ground gets a refusal, never a guess). matchPills enforces this
10
+ // mechanically, not by convention:
11
+ // 1. it takes the candidate list as a plain argument every call — nothing
12
+ // here caches a pill set across calls, so a stale "take lamp" from a
13
+ // turn ago can never surface after the lamp left the room;
14
+ // 2. every returned match is one of the caller's own candidate objects
15
+ // (`===`), never a new string built from typed characters;
16
+ // 3. accepting a completion (Tab/ArrowRight in createPillComplete) always
17
+ // writes the accepted candidate's OWN `.command`, never the typed
18
+ // prefix plus a computed suffix.
19
+ // Do not build on suggestionsForTerm in adventure-viz.mjs instead of this
20
+ // module — it returns ontology neighbours ("lamp" -> "lantern, light") that
21
+ // are grounded as vocabulary but not as affordances ("take lantern" still
22
+ // declines), which is exactly the guess this module exists to refuse. The
23
+ // two mechanisms are named differently on purpose (nothing here starts with
24
+ // "suggestions") because a page can carry both at once and they must never
25
+ // be confused for one another.
26
+ //
27
+ // SPLICE SAFETY: pages embed the runtime pieces by `.toString()`-splicing
28
+ // them into their inline IIFE, verbatim, as top-level functions. `matchPills`,
29
+ // `pillCandidates` and `createPillComplete` are written with NO reference to
30
+ // any module-level binding (no closed-over const, no import) so that
31
+ // splicing is safe — an adopting page must splice all THREE of those, under
32
+ // these exact names, because createPillComplete calls the other two by bare
33
+ // name at runtime and that call only resolves if all three sit as siblings
34
+ // in the same inline script. `pillCompleteMarkup` is different: it is a
35
+ // markup BUILDER, the same role `shareOverlayHtml` plays in
36
+ // share-overlay-viz.mjs — it runs once, server/module-side, to produce the
37
+ // initial HTML string a page embeds, and is never spliced into the client
38
+ // script. That is the only reason it may (and does) import `escapeHtml`.
39
+ //
40
+ // Only `escapeHtml` is imported from viz-theme.mjs, and only pillCompleteMarkup
41
+ // uses it, for exactly the reason above.
42
+ import { escapeHtml } from "./viz-theme.mjs";
43
+
44
+ /**
45
+ * Normalizes a page's own pill list into `{ command, label }` pairs.
46
+ * Accepts either shape a page already has lying around: a bare command
47
+ * string (adventure-viz.mjs's `roomAffordances`/`pillsForRoom` return
48
+ * `string[]`), or an object already carrying a separate `label` (mud-viz.mjs's
49
+ * `renderChatPills` pills, where a dug object's command outranks its display
50
+ * name — `{command:"take carrot-1", label:"take carrot"}`). A string pill's
51
+ * label defaults to the command itself; an object pill missing `label`
52
+ * falls back to its own `command` the same way. Pure, self-contained, no
53
+ * outer refs — safe to call from either a splice-safe context or an
54
+ * ordinary module-side render.
55
+ */
56
+ export function pillCandidates(pills) {
57
+ const out = [];
58
+ for (const pill of pills || []) {
59
+ if (typeof pill === "string") {
60
+ out.push({ command: pill, label: pill });
61
+ } else if (pill && typeof pill === "object") {
62
+ const command = String(pill.command == null ? "" : pill.command);
63
+ const label = pill.label == null ? command : String(pill.label);
64
+ out.push({ command, label });
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Matches `typed` against `candidates` (already `{command,label}` pairs —
72
+ * pass them through pillCandidates first). Every entry `matchPills` returns
73
+ * is `===` one of the objects in `candidates`; nothing is ever constructed
74
+ * from `typed`'s own characters. `candidates` is read fresh on every call —
75
+ * this function holds no state of its own, so a stale pill set is only ever
76
+ * the caller's mistake, never this module's.
77
+ *
78
+ * Normalization: lowercase, collapse internal whitespace runs to one space,
79
+ * trim the START only. Trailing whitespace on `typed` is kept on purpose —
80
+ * "take " is a real, useful prefix of "take lamp" that a start-and-end trim
81
+ * would destroy.
82
+ *
83
+ * Tier 1 (whole-string prefix) outranks tier 2 (word-boundary prefix,
84
+ * checked only when `typed` itself contains no space — a boundary match
85
+ * inside a multi-word typed string would be guessing which word the caret
86
+ * means). Both tiers match against `command` OR `label`. Within a tier:
87
+ * earlier match position first, then shorter command, then the candidate's
88
+ * own position in the input array — so the result is a pure function of the
89
+ * caller's own ordering, never of anything hidden in here. Capped at 8.
90
+ *
91
+ * Empty or whitespace-only `typed` returns `{ matches: [], top: null,
92
+ * ghost: "", tier: null }` deliberately, not "show everything" — the rail
93
+ * this completes against is already showing everything, and the input's own
94
+ * placeholder already occupies those pixels.
95
+ *
96
+ * `ghost` is the remainder of `top.command` after the typed prefix, and is
97
+ * only ever non-empty when the WHOLE match came from `command` itself at
98
+ * tier 1 (not merely from `label`) — a suffix sliced off `command` only
99
+ * means anything when `command` is what actually carried the prefix. A
100
+ * tier-2 match has no inline suffix form at all; it surfaces through the
101
+ * rail highlight and a live region instead.
102
+ *
103
+ * Self-contained, `.toString()`-splice-safe: no outer refs, tier thresholds
104
+ * and the cap of 8 are literals in the body.
105
+ */
106
+ export function matchPills(candidates, typed, opts = {}) {
107
+ const cap = 8;
108
+ const normalize = function (s) {
109
+ return String(s == null ? "" : s).replace(/^\s+/, "").toLowerCase().replace(/\s+/g, " ");
110
+ };
111
+ const wordStartPosition = function (text, prefix) {
112
+ let offset = 0;
113
+ const words = text.split(" ");
114
+ for (let i = 0; i < words.length; i++) {
115
+ if (words[i].indexOf(prefix) === 0) return offset;
116
+ offset += words[i].length + 1;
117
+ }
118
+ return -1;
119
+ };
120
+
121
+ const typedNorm = normalize(typed);
122
+ if (typedNorm.trim() === "") return { matches: [], top: null, ghost: "", tier: null };
123
+ const hasSpace = typedNorm.indexOf(" ") !== -1;
124
+
125
+ const scored = [];
126
+ const list = candidates || [];
127
+ for (let i = 0; i < list.length; i++) {
128
+ const candidate = list[i];
129
+ const commandNorm = normalize(candidate.command);
130
+ const labelNorm = normalize(candidate.label);
131
+ let tier = null;
132
+ let position = 0;
133
+ let commandCarriedTier1 = false;
134
+
135
+ if (commandNorm.indexOf(typedNorm) === 0) {
136
+ tier = 1;
137
+ position = 0;
138
+ commandCarriedTier1 = true;
139
+ } else if (labelNorm.indexOf(typedNorm) === 0) {
140
+ tier = 1;
141
+ position = 0;
142
+ } else if (!hasSpace) {
143
+ const commandPos = wordStartPosition(commandNorm, typedNorm);
144
+ const labelPos = wordStartPosition(labelNorm, typedNorm);
145
+ if (commandPos !== -1 || labelPos !== -1) {
146
+ tier = 2;
147
+ position = commandPos === -1 ? labelPos : (labelPos === -1 ? commandPos : Math.min(commandPos, labelPos));
148
+ }
149
+ }
150
+
151
+ if (tier === null) continue;
152
+ scored.push({
153
+ candidate: candidate,
154
+ tier: tier,
155
+ position: position,
156
+ commandCarriedTier1: commandCarriedTier1,
157
+ commandLen: commandNorm.length,
158
+ index: i,
159
+ });
160
+ }
161
+
162
+ scored.sort(function (a, b) {
163
+ if (a.tier !== b.tier) return a.tier - b.tier;
164
+ if (a.position !== b.position) return a.position - b.position;
165
+ if (a.commandLen !== b.commandLen) return a.commandLen - b.commandLen;
166
+ return a.index - b.index;
167
+ });
168
+
169
+ const capped = scored.slice(0, cap);
170
+ const matches = capped.map(function (s) { return s.candidate; });
171
+ const top = matches.length ? matches[0] : null;
172
+ const topScored = capped.length ? capped[0] : null;
173
+ let ghost = "";
174
+ if (topScored && topScored.tier === 1 && topScored.commandCarriedTier1) {
175
+ ghost = top.command.slice(typedNorm.length);
176
+ }
177
+ return { matches: matches, top: top, ghost: ghost, tier: topScored ? topScored.tier : null };
178
+ }
179
+
180
+ /**
181
+ * The markup wrapper an adopting page renders once, around an `<input>` it
182
+ * already builds itself: a positioning field, the inline ghost span
183
+ * (`aria-hidden`, since its text is a decoration the live region already
184
+ * announces), and a visually-hidden `aria-live="polite"` status paragraph
185
+ * for the tier-2 case (which has no inline ghost to read). `inputHtml` is
186
+ * the caller's own already-built `<input ...>` tag, dropped in verbatim.
187
+ * `inputId` is the id that tag carries — the ghost and status elements are
188
+ * addressed off it (`${inputId}-pc-ghost`, `${inputId}-pc-status`) so the
189
+ * caller can `getElementById` them right after inserting this markup, before
190
+ * constructing `createPillComplete`.
191
+ *
192
+ * Runs once at page-render time (module- or server-side), the same role
193
+ * `shareOverlayHtml` plays in share-overlay-viz.mjs — it is NOT one of the
194
+ * three functions an adopting page splices into its inline script, so it is
195
+ * the one export in this module allowed to import `escapeHtml`.
196
+ */
197
+ export function pillCompleteMarkup({ inputId, inputHtml }) {
198
+ const esc = escapeHtml;
199
+ const id = esc(String(inputId == null ? "" : inputId));
200
+ return `<div class="pc-field">
201
+ ${inputHtml}
202
+ <span class="pc-ghost" id="${id}-pc-ghost" aria-hidden="true"></span>
203
+ </div>
204
+ <p class="pc-status" id="${id}-pc-status" aria-live="polite"></p>`;
205
+ }
206
+
207
+ /**
208
+ * The browser controller. `getCandidates` is a closure the caller supplies
209
+ * that returns the CURRENT pills (already through pillCandidates, or raw —
210
+ * this calls pillCandidates on the result itself); it is invoked fresh on
211
+ * every recompute, never cached here, which is what keeps a completion from
212
+ * ever outliving the pill that grounded it.
213
+ *
214
+ * Keyboard contract:
215
+ * - any `input` event recomputes and repaints, no debounce;
216
+ * - Tab accepts `top.command` (never the typed text plus a suffix), moves
217
+ * the caret to the end, clears the ghost, keeps focus; it never submits.
218
+ * `preventDefault` fires ONLY when a candidate is actually showing, so a
219
+ * keyboard user is never trapped on an empty match;
220
+ * - ArrowRight accepts the same way, but only when the caret already sits
221
+ * at the end of the input AND a tier-1 ghost is showing — otherwise it
222
+ * moves the caret like it always does;
223
+ * - ArrowDown/ArrowUp move a highlighted index through the rail, wrapping,
224
+ * for a keyboard/screen-reader user browsing the options; they do not
225
+ * change what Tab or ArrowRight accept (that is always `top`, the same
226
+ * candidate the inline ghost already promised);
227
+ * - Enter does nothing here on purpose — the page's own submit handler
228
+ * reads whatever is literally in the box. Filling and submitting are two
229
+ * separate, deliberate gestures, never one;
230
+ * - Escape dismisses the ghost/rail-highlight/status text until the value
231
+ * next changes (the next `input` event un-dismisses);
232
+ * - anything happening while `event.isComposing` is skipped entirely, and
233
+ * a recompute runs on `compositionend` instead.
234
+ *
235
+ * The input's value is only ever written by a deliberate Tab/ArrowRight
236
+ * accept — never by typing, never by a browser-style "insert and select"
237
+ * trick, because that would put text in the box nobody typed and a
238
+ * following Enter would submit words the user never wrote.
239
+ *
240
+ * `railEl` is the page's own existing pill rail, promoted to the combobox
241
+ * popup rather than adding a second surface: this sets `role="listbox"` on
242
+ * it and looks up each pill's DOM node by its own `data-command` attribute
243
+ * (the attribute mud-viz.mjs's `renderChatPills` already stamps on every
244
+ * pill button) to toggle `aria-selected`/a `.pc-active` class and drive
245
+ * `aria-activedescendant` on the input. A page whose pills don't carry
246
+ * `data-command` yet still gets full keyboard completion; it only misses the
247
+ * DOM-level highlight/activedescendant wiring until its own pill markup adds
248
+ * that attribute.
249
+ *
250
+ * Self-contained, `.toString()`-splice-safe: no outer refs. Calls
251
+ * `pillCandidates` and `matchPills` by bare name — splice those two
252
+ * alongside this one, under these exact names, in the same inline script.
253
+ *
254
+ * Returns `{ refresh, destroy }`. `refresh` recomputes and repaints against
255
+ * the CURRENT input value and CURRENT candidates — call it from the page's
256
+ * own render pass whenever the pill set changes for a reason other than
257
+ * typing (the player moved rooms, an object was taken), so the ghost and
258
+ * rail never lag behind what is actually grounded right now. `destroy`
259
+ * removes every listener this attached.
260
+ */
261
+ export function createPillComplete({ input, ghostEl, statusEl, railEl, getCandidates, onAccept }) {
262
+ let state = { matches: [], top: null, ghost: "", tier: null };
263
+ let activeIndex = 0;
264
+ let dismissed = false;
265
+
266
+ function pillNodeFor(command) {
267
+ if (!railEl) return null;
268
+ const nodes = railEl.querySelectorAll("[data-command]");
269
+ for (let i = 0; i < nodes.length; i++) {
270
+ if (nodes[i].getAttribute("data-command") === command) return nodes[i];
271
+ }
272
+ return null;
273
+ }
274
+
275
+ function clearHighlights() {
276
+ if (!railEl) return;
277
+ const nodes = railEl.querySelectorAll("[data-command]");
278
+ for (let i = 0; i < nodes.length; i++) {
279
+ nodes[i].removeAttribute("aria-selected");
280
+ nodes[i].classList.remove("pc-active");
281
+ }
282
+ }
283
+
284
+ function syncGhostScroll() {
285
+ if (ghostEl) ghostEl.scrollLeft = input.scrollLeft;
286
+ }
287
+
288
+ function syncGhostBox() {
289
+ if (!ghostEl || !window.getComputedStyle) return;
290
+ const style = window.getComputedStyle(input);
291
+ const props = [
292
+ "fontFamily", "fontSize", "fontWeight", "letterSpacing", "lineHeight",
293
+ "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
294
+ "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
295
+ "textIndent",
296
+ ];
297
+ for (let i = 0; i < props.length; i++) ghostEl.style[props[i]] = style[props[i]];
298
+ syncGhostScroll();
299
+ }
300
+
301
+ function paint() {
302
+ if (railEl) railEl.setAttribute("role", "listbox");
303
+ if (statusEl) statusEl.setAttribute("aria-live", "polite");
304
+ if (ghostEl) ghostEl.setAttribute("aria-hidden", "true");
305
+
306
+ if (dismissed || !state.matches.length) {
307
+ if (ghostEl) ghostEl.textContent = "";
308
+ if (statusEl) statusEl.textContent = "";
309
+ input.setAttribute("aria-expanded", "false");
310
+ input.removeAttribute("aria-activedescendant");
311
+ clearHighlights();
312
+ return;
313
+ }
314
+
315
+ input.setAttribute("aria-expanded", "true");
316
+ if (ghostEl) {
317
+ ghostEl.textContent = "";
318
+ if (state.tier === 1 && state.ghost) {
319
+ const typedPart = document.createElement("span");
320
+ typedPart.className = "pc-ghost-typed";
321
+ typedPart.textContent = input.value;
322
+ const suffixPart = document.createElement("span");
323
+ suffixPart.className = "pc-ghost-suffix";
324
+ suffixPart.textContent = state.ghost;
325
+ ghostEl.appendChild(typedPart);
326
+ ghostEl.appendChild(suffixPart);
327
+ }
328
+ syncGhostScroll();
329
+ }
330
+ if (statusEl) {
331
+ statusEl.textContent = (state.tier === 2 && state.top) ? (state.top.label + " — press tab to fill in") : "";
332
+ }
333
+
334
+ clearHighlights();
335
+ const active = state.matches[activeIndex] || state.top;
336
+ const node = active ? pillNodeFor(active.command) : null;
337
+ if (node) {
338
+ node.setAttribute("aria-selected", "true");
339
+ node.classList.add("pc-active");
340
+ if (node.id) input.setAttribute("aria-activedescendant", node.id);
341
+ else input.removeAttribute("aria-activedescendant");
342
+ } else {
343
+ input.removeAttribute("aria-activedescendant");
344
+ }
345
+ }
346
+
347
+ function recompute() {
348
+ const raw = getCandidates ? getCandidates() : [];
349
+ const candidates = pillCandidates(raw);
350
+ state = matchPills(candidates, input.value);
351
+ activeIndex = 0;
352
+ paint();
353
+ }
354
+
355
+ function acceptTop() {
356
+ if (!state.top) return;
357
+ const accepted = state.top;
358
+ input.value = accepted.command;
359
+ input.setSelectionRange(input.value.length, input.value.length);
360
+ dismissed = false;
361
+ if (onAccept) onAccept(accepted);
362
+ recompute();
363
+ }
364
+
365
+ function onKeyDown(e) {
366
+ if (e.isComposing) return;
367
+ if (e.key === "Tab") {
368
+ if (!dismissed && state.matches.length && state.top) {
369
+ e.preventDefault();
370
+ acceptTop();
371
+ }
372
+ return;
373
+ }
374
+ if (e.key === "ArrowRight") {
375
+ const atEnd = input.selectionStart === input.value.length && input.selectionEnd === input.value.length;
376
+ if (!dismissed && atEnd && state.tier === 1 && state.ghost) {
377
+ e.preventDefault();
378
+ acceptTop();
379
+ }
380
+ return;
381
+ }
382
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
383
+ if (dismissed || !state.matches.length) return;
384
+ e.preventDefault();
385
+ const delta = e.key === "ArrowDown" ? 1 : -1;
386
+ activeIndex = (activeIndex + delta + state.matches.length) % state.matches.length;
387
+ paint();
388
+ return;
389
+ }
390
+ if (e.key === "Escape") {
391
+ dismissed = true;
392
+ paint();
393
+ return;
394
+ }
395
+ }
396
+
397
+ function onInput(e) {
398
+ if (e.isComposing) return;
399
+ dismissed = false;
400
+ recompute();
401
+ }
402
+
403
+ function onCompositionEnd() {
404
+ recompute();
405
+ }
406
+
407
+ function onScroll() {
408
+ syncGhostScroll();
409
+ }
410
+
411
+ function onResize() {
412
+ syncGhostBox();
413
+ }
414
+
415
+ input.setAttribute("role", "combobox");
416
+ input.setAttribute("aria-autocomplete", "both");
417
+ if (railEl && railEl.id) input.setAttribute("aria-controls", railEl.id);
418
+ input.setAttribute("aria-expanded", "false");
419
+
420
+ input.addEventListener("keydown", onKeyDown);
421
+ input.addEventListener("input", onInput);
422
+ input.addEventListener("compositionend", onCompositionEnd);
423
+ input.addEventListener("scroll", onScroll);
424
+ window.addEventListener("resize", onResize);
425
+
426
+ syncGhostBox();
427
+ recompute();
428
+
429
+ return {
430
+ refresh: recompute,
431
+ destroy: function () {
432
+ input.removeEventListener("keydown", onKeyDown);
433
+ input.removeEventListener("input", onInput);
434
+ input.removeEventListener("compositionend", onCompositionEnd);
435
+ input.removeEventListener("scroll", onScroll);
436
+ window.removeEventListener("resize", onResize);
437
+ },
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Geometry and layering only — never the input's own font or text colour,
443
+ * which are copied at runtime from its computed style (see createPillComplete's
444
+ * `syncGhostBox`) so a page's own typography is honoured with zero
445
+ * page-specific CSS here. `background: transparent` on the real `<input>` is
446
+ * the one exception: it is structural, not a themed colour choice — the
447
+ * ghost text sits in a layer BEHIND the input, so the input has to be
448
+ * see-through for the ghost's suffix to read at all. Themed through `--pc-*`
449
+ * custom properties defaulting to the site's shared tokens, the way
450
+ * share-overlay-viz.mjs's `--so-*` variables do.
451
+ */
452
+ export const PILL_COMPLETE_CSS = `
453
+ .pc-field {
454
+ --pc-ghost-color: var(--muted, #6E7168);
455
+ --pc-highlight: var(--corpus-soft, rgba(90, 128, 172, .12));
456
+ --pc-highlight-line: var(--corpus, #5A80AC);
457
+ position: relative;
458
+ display: block;
459
+ }
460
+ .pc-field > input {
461
+ position: relative;
462
+ z-index: 1;
463
+ background: transparent;
464
+ }
465
+ .pc-ghost {
466
+ position: absolute;
467
+ inset: 0;
468
+ z-index: 0;
469
+ display: flex;
470
+ align-items: center;
471
+ overflow: hidden;
472
+ white-space: pre;
473
+ pointer-events: none;
474
+ box-sizing: border-box;
475
+ }
476
+ .pc-ghost-typed { color: transparent; }
477
+ .pc-ghost-suffix { color: var(--pc-ghost-color); }
478
+ .pc-status {
479
+ position: absolute;
480
+ width: 1px;
481
+ height: 1px;
482
+ margin: -1px;
483
+ overflow: hidden;
484
+ clip: rect(0, 0, 0, 0);
485
+ white-space: nowrap;
486
+ }
487
+ .pc-active {
488
+ background: var(--pc-highlight);
489
+ outline: 2px solid var(--pc-highlight-line);
490
+ outline-offset: -2px;
491
+ }
492
+ @media (prefers-reduced-motion: reduce) {
493
+ .pc-ghost, .pc-active { transition: none !important; }
494
+ }
495
+ `;