@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.
- package/README.md +1 -1
- package/data/mudiii-assets.json +201 -0
- package/package.json +4 -1
- package/src/domain/agent-belief.mjs +96 -0
- package/src/domain/answer-variants.json +1 -1
- package/src/domain/game-config.mjs +65 -0
- package/src/services/adventure-editor.mjs +36 -0
- package/src/services/adventure.mjs +45 -15
- package/src/services/chat.mjs +1 -1
- package/src/services/mud-editor.mjs +53 -0
- package/src/services/pill-complete.mjs +495 -0
- package/src/services/spider-fly.mjs +14 -64
- package/src/services/world-teach.mjs +214 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +18 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +105 -100
|
@@ -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
|
+
`;
|