@tablefolk/menus 0.0.0-stage → 0.1.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -2
  3. package/box.js +128 -0
  4. package/index.js +108 -0
  5. package/package.json +26 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @tablefolk/menus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ JRPG-style menus for games and apps. The player clicks a choice, presses its
4
+ number, uses the arrows and Enter, or types or says what they want.
5
+
6
+ Words are matched to a choice cheapest first:
7
+
8
+ 1. a number ("2", "the second one", "the last");
9
+ 2. the label or a keyword;
10
+ 3. shared words;
11
+ 4. meaning, through an embedding model you give it;
12
+ 5. a language model you give it, picking a number.
13
+
14
+ Any step can answer "none of these".
15
+
16
+ ```js
17
+ import { match, modelChooser, createMenuBox } from '@tablefolk/menus';
18
+ const menu = { title: 'Brakka', text: 'What do you need?', other: true,
19
+ items: [{ id: 'buy', label: 'Buy a sword' }, { id: 'fix', label: 'Repair my armour', keywords: ['mend'] }, { id: 'bye', label: 'Leave' }] };
20
+
21
+ await match(menu, 'can you mend it') // { item: {id:'fix',...}, by: 'keyword', score: 1 }
22
+ // close between a few choices by meaning, with no model to decide: { none: true, by: 'unsure', unsure: [items] }
23
+ // (the box then asks "did you mean 1. ... or 2. ...?")
24
+
25
+ const box = createMenuBox({
26
+ matchOptions: () => ({ choose: modelChooser((messages, o) => brain.chat(messages, o)) }), // optional
27
+ onChoose: (item, { how, text }) => console.log(item.id, how),
28
+ onOther: text => console.log('none fit:', text),
29
+ });
30
+ await box.open(menu); // { item, how, text } | { other: true, text } | null
31
+ box.offer(speechText); // words from your own text field or speech input
32
+ ```
33
+
34
+ It needs no models of its own. Give it `embed(query, texts)` (similarities)
35
+ and `choose` (any chat model: `modelChooser`, or `@tablefolk/brain`'s
36
+ `pick`). The box brings a default look (`.menu-box`); `styles: false` leaves
37
+ the look to you.
38
+
39
+ Part of [Tablefolk](https://github.com/runvnc/tablefolk) (`libs/`). MIT.
package/box.js ADDED
@@ -0,0 +1,128 @@
1
+ // The menu box on screen: click a choice, press 1-9, arrows and Enter, Esc
2
+ // closes; words (typed into your own field, or from speech) go to offer().
3
+ //
4
+ // import { createMenuBox } from '@tablefolk/menus';
5
+ // const box = createMenuBox({
6
+ // matchOptions: () => ({ embed, choose }), // what match() may use (see index.js)
7
+ // onChoose: (item, { menu, how, text }) => {},
8
+ // onOther: (text, menu) => {}, // words that fit none, when menu.other
9
+ // });
10
+ // const r = await box.open(menu); // { item, how, text } | { other: true, text } | null (closed)
11
+ // box.offer('the second one') // true when the open menu took the words
12
+ // box.close(); box.active // the open menu or null
13
+ // Styles: a default look is added to the page once (injectMenuStyles; pass
14
+ // styles: false to style .menu-box yourself).
15
+ import { match, normalizeMenu } from './index.js';
16
+
17
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
18
+
19
+ export const MENU_CSS = `
20
+ .menu-box { position:fixed; left:50%; bottom:92px; transform:translateX(-50%); z-index:60; width:min(440px, calc(100vw - 32px));
21
+ background:linear-gradient(180deg, rgba(22,26,44,.97), rgba(12,14,26,.97)); border:2px solid #d9c27a; border-radius:12px;
22
+ box-shadow:0 0 0 3px rgba(0,0,0,.55), 0 14px 40px rgba(0,0,0,.6); color:#f3ecd6; padding:10px 12px 8px; font-size:15px; box-sizing:border-box; }
23
+ .menu-box[hidden] { display:none; }
24
+ .menu-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
25
+ .menu-head b { flex:1; color:#f2d27c; font-size:15px; letter-spacing:.02em; }
26
+ .menu-x { flex:0 0 auto; width:auto; background:none; border:0; color:#cbbf9a; font-size:15px; cursor:pointer; padding:2px 6px; }
27
+ .menu-text { margin:6px 0 4px; line-height:1.35; }
28
+ .menu-items { list-style:none; margin:6px 0 4px; padding:0; display:flex; flex-direction:column; gap:3px; max-height:46vh; overflow:auto; }
29
+ .menu-items button { display:flex; align-items:center; gap:10px; width:100%; text-align:left; background:transparent; border:1px solid transparent; border-radius:8px;
30
+ color:inherit; font:inherit; padding:7px 10px 7px 26px; cursor:pointer; position:relative; }
31
+ .menu-items button.sel { background:rgba(217,194,122,.14); border-color:rgba(217,194,122,.45); }
32
+ .menu-items button.sel::before { content:"▶"; position:absolute; left:8px; color:#f2d27c; font-size:11px; }
33
+ .menu-num { min-width:18px; color:#9fb3d9; font-size:12px; font-variant-numeric:tabular-nums; }
34
+ .menu-hint { font-size:11.5px; color:#a8a08a; margin-top:4px; }
35
+ @media (max-width: 600px) { .menu-box { bottom:76px; font-size:14px; } .menu-items button { padding:9px 10px 9px 26px; } }
36
+ `;
37
+ export function injectMenuStyles(doc = document) {
38
+ if (doc.getElementById('tf-menu-styles')) return;
39
+ const s = doc.createElement('style'); s.id = 'tf-menu-styles'; s.textContent = MENU_CSS;
40
+ doc.head.prepend(s); // first, so the page's own rules win
41
+ }
42
+
43
+ export function createMenuBox({ parent = null, id = 'menuBox', matchOptions = () => ({}), onChoose = null, onOther = null, onOpen = null, styles = true, keys = true, log = null } = {}) {
44
+ if (styles) injectMenuStyles();
45
+ let cur = null; // { menu, ctx, resolve, sel, busy, hint }
46
+ let el = null;
47
+ function box() {
48
+ if (el) return el;
49
+ el = document.getElementById(id);
50
+ if (!el) {
51
+ el = document.createElement('div'); el.id = id; el.className = 'menu-box'; el.setAttribute('role', 'dialog'); el.hidden = true;
52
+ (parent || document.body).appendChild(el);
53
+ }
54
+ el.addEventListener('click', e => {
55
+ const b = e.target.closest('[data-menu-item]'); if (b) { choose(+b.dataset.menuItem, 'click'); return; }
56
+ if (e.target.closest('[data-menu-close]')) close();
57
+ });
58
+ el.addEventListener('mousemove', e => { const b = e.target.closest('[data-menu-item]'); if (b && cur && +b.dataset.menuItem !== cur.sel) { cur.sel = +b.dataset.menuItem; mark(); } });
59
+ return el;
60
+ }
61
+ function render() {
62
+ const el = box(); if (!cur) { el.hidden = true; return; }
63
+ const m = cur.menu;
64
+ el.innerHTML = `<div class="menu-head"><b>${esc(m.title || cur.ctx.title || '')}</b><button type="button" class="menu-x" data-menu-close aria-label="close">✕</button></div>`
65
+ + (m.text ? `<p class="menu-text">${esc(m.text)}</p>` : '')
66
+ + `<ol class="menu-items">${m.items.map((i, n) => `<li><button type="button" data-menu-item="${n}" class="${n === cur.sel ? 'sel' : ''}"><span class="menu-num">${n + 1}</span>${esc(i.label)}</button></li>`).join('')}</ol>`
67
+ + `<div class="menu-hint" aria-live="polite">${esc(cur.hint || `click, press 1-${Math.min(9, m.items.length)}, or type or say it${m.other ? ' (or anything else)' : ''} · Esc closes`)}</div>`;
68
+ el.hidden = false;
69
+ el.setAttribute('aria-label', m.title || 'menu');
70
+ }
71
+ function mark() { box().querySelectorAll('[data-menu-item]').forEach(b => b.classList.toggle('sel', +b.dataset.menuItem === cur?.sel)); }
72
+ function hint(text) { if (!cur) return; cur.hint = text; const h = box().querySelector('.menu-hint'); if (h) h.textContent = text; }
73
+
74
+ // ctx: anything the caller wants back in onChoose (the Tablefolk game: agentId)
75
+ function open(menuIn, ctx = {}) {
76
+ const menu = normalizeMenu(menuIn);
77
+ if (!menu) { log?.('no such menu', menuIn); return Promise.resolve(null); }
78
+ if (cur) finish(null);
79
+ return new Promise(res => {
80
+ cur = { menu, ctx, resolve: res, sel: 0, hint: '' };
81
+ render();
82
+ onOpen?.(menu, ctx);
83
+ });
84
+ }
85
+ function finish(result) { const c = cur; cur = null; render(); c?.resolve(result); }
86
+ function close() { finish(null); }
87
+ function choose(n, how, text) {
88
+ if (!cur) return;
89
+ const { menu, ctx } = cur, item = menu.items[n]; if (!item) return;
90
+ finish({ item, how, text: text ?? null });
91
+ onChoose?.(item, { menu, how, text: text ?? null, ...ctx });
92
+ }
93
+ async function offer(text) {
94
+ if (!cur) return false;
95
+ if (cur.busy) return true;
96
+ const c = cur; c.busy = true; hint('working out which one you meant…');
97
+ let r; try { r = await match(c.menu, text, { log, ...(matchOptions() || {}) }); } finally { c.busy = false; }
98
+ if (cur !== c) return true;
99
+ if (r.item) { log?.('matched', JSON.stringify(text), '->', r.item.id, 'by', r.by, r.score ?? ''); choose(c.menu.items.findIndex(i => i.id === r.item.id), 'words', text); return true; }
100
+ if (c.menu.other) { finish({ other: true, text }); onOther?.(text, c.menu, c.ctx); return true; }
101
+ if (r.unsure?.length > 1) {
102
+ const nums = r.unsure.map(i => c.menu.items.findIndex(x => x.id === i.id) + 1);
103
+ c.sel = nums[0] - 1; mark();
104
+ hint(`did you mean ${r.unsure.map((i, k) => `${nums[k]}. "${i.label}"`).join(' or ')}? Press its number, or say it another way`);
105
+ return true;
106
+ }
107
+ hint(`"${String(text).slice(0, 50)}" isn't one of these: pick one, or say it another way`);
108
+ return true;
109
+ }
110
+ // keys while a menu is open (not while typing in a field)
111
+ const onKey = e => {
112
+ if (!cur) return;
113
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '') || document.activeElement?.isContentEditable;
114
+ if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(); return; }
115
+ if (typing) return;
116
+ const n = cur.menu.items.length;
117
+ if (/^[1-9]$/.test(e.key) && +e.key <= n) { e.preventDefault(); e.stopImmediatePropagation(); choose(+e.key - 1, 'key'); return; }
118
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopImmediatePropagation(); cur.sel = (cur.sel + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; mark(); return; }
119
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopImmediatePropagation(); choose(cur.sel, 'key'); }
120
+ };
121
+ if (keys) addEventListener('keydown', onKey, true);
122
+ return {
123
+ open, close, offer, choose, hint,
124
+ get active() { return cur ? cur.menu : null; },
125
+ get context() { return cur ? cur.ctx : null; },
126
+ destroy() { removeEventListener('keydown', onKey, true); close(); el?.remove(); },
127
+ };
128
+ }
package/index.js ADDED
@@ -0,0 +1,108 @@
1
+ // @tablefolk/menus -- JRPG-style menus: a list of choices a player clicks,
2
+ // presses a number for, types, or says. This module is the matching (no DOM);
3
+ // box.js is the menu box on screen.
4
+ //
5
+ // A menu:
6
+ // { id: 'smith', title: 'Brakka', text: 'What do you need?',
7
+ // items: [ { id: 'buy', label: 'Buy a sword (30 gold)', say: 'I would like to buy a sword.' },
8
+ // { id: 'ask', label: 'Ask about the wolves', keywords: ['wolf', 'wolves'] },
9
+ // { id: 'bye', label: 'Leave' } ],
10
+ // other: true } // words that fit no choice are passed on (else "not understood")
11
+ // Items may carry any other fields (the Tablefolk game uses say, do, menu).
12
+ //
13
+ // Words are matched to a choice in steps, cheapest first: a number ("2",
14
+ // "two", "the second one", "the last"), the label or a keyword, shared words,
15
+ // then meaning (an embedding model, when given and its best choice is clear),
16
+ // then a language model asked to pick a number. Every step can answer "none".
17
+ //
18
+ // import { match, normalizeMenu, modelChooser } from '@tablefolk/menus';
19
+ // await match(menu, 'can you fix my blade', {
20
+ // embed: async (query, texts) => texts.map(t => cosine(...)), // optional
21
+ // choose: modelChooser(async (messages, opts) => text), // optional: any chat model
22
+ // }) -> { item, by: 'number'|'label'|'keyword'|'words'|'meaning'|'model', score } | { none: true, by }
23
+ // (none with unsure: [items]: meaning found a few close choices and nothing decided between them)
24
+
25
+ export const norm = s => String(s || '').toLowerCase().replace(/[’']/g, '').replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
26
+ const STOP = new Set('a an the to of and or i im me my you your we us it is are be do can could would will want like please some about for with on in at this that these those what whats one just get got let lets go ok okay yes no have need'.split(' '));
27
+ // content words, roughly stemmed ("repaired", "repairs", "repairing" -> "repair")
28
+ const stem = w => w.length > 5 && /(ing|ed)$/.test(w) ? w.replace(/(ing|ed)$/, '') : w.length > 3 ? w.replace(/ies$/, 'y').replace(/(es|s)$/, '') : w;
29
+ export const words = s => norm(s).split(' ').filter(w => w.length > 2 && !STOP.has(w)).map(stem);
30
+ const NUM = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8, ninth: 9, last: -1 };
31
+ // embedding cosine (calibrated on EmbeddingGemma, a query against "label. say"):
32
+ // the best must reach EMBED_MIN and lead the next by EMBED_GAP, else the model decides
33
+ export const EMBED_MIN = 0.74, EMBED_GAP = 0.06; // unrelated choices score 0.6-0.7 (2026-11-12: right picks 0.76-0.91)
34
+
35
+ // a usable copy: items with ids and labels, at most 24
36
+ export function normalizeMenu(menu) {
37
+ if (!menu || !Array.isArray(menu.items) || !menu.items.length) return null;
38
+ return { ...menu, items: menu.items.filter(i => i && (i.label || i.say)).slice(0, 24).map((i, n) => ({ ...i, id: String(i.id || 'item' + (n + 1)), label: String(i.label || i.say) })) };
39
+ }
40
+ export const itemText = i => [i.label, i.say].filter(Boolean).join('. ');
41
+
42
+ export async function match(menuIn, text, { embed = null, choose = null, embedMin = EMBED_MIN, embedGap = EMBED_GAP, log = null } = {}) {
43
+ const menu = normalizeMenu(menuIn); if (!menu) return { none: true, by: 'no menu' };
44
+ const items = menu.items, t = norm(text);
45
+ if (!t) return { none: true, by: 'empty' };
46
+ // a number: "2", "number 2", "option two", "the second one", "the last"
47
+ const num = t.match(/^(?:(?:number|option|choice|the)\s+)?(\d+|[a-z]+)(?:\s+(?:one|option|choice|please))?$/);
48
+ if (num) {
49
+ let n = /^\d+$/.test(num[1]) ? +num[1] : NUM[num[1]];
50
+ if (n === -1) n = items.length;
51
+ if (n >= 1 && n <= items.length) return { item: items[n - 1], by: 'number', score: 1 };
52
+ }
53
+ // the label itself, or one of its keywords
54
+ for (const i of items) if (norm(i.label) === t || norm(i.say) === t) return { item: i, by: 'label', score: 1 };
55
+ const kw = items.filter(i => (i.keywords || []).some(k => new RegExp('\\b' + norm(k).replace(/ /g, '\\s+') + '\\b').test(t)));
56
+ if (kw.length === 1) return { item: kw[0], by: 'keyword', score: 1 };
57
+ // shared words: most of a choice's words said, and more than any other choice
58
+ const tw = new Set(words(t));
59
+ const overlap = items.map(i => { const iw = [...new Set(words(i.label))]; return iw.length ? iw.filter(w => tw.has(w)).length / iw.length : 0; });
60
+ const best = Math.max(...overlap), at = overlap.indexOf(best);
61
+ if (best >= 0.6 && overlap.filter(v => v === best).length === 1) return { item: items[at], by: 'words', score: best };
62
+ // meaning: an embedding model, when its pick is clear
63
+ let unsure = null;
64
+ if (embed) {
65
+ try {
66
+ const texts = items.map(itemText), r = await embed(text, texts);
67
+ if (r) {
68
+ const sims = r instanceof Map ? texts.map(x => r.get(x) ?? 0) : [...r];
69
+ const scored = sims.map((s, n) => [s, n]).sort((a, b) => b[0] - a[0]);
70
+ const [s1, n1] = scored[0], s2 = scored[1]?.[0] ?? 0;
71
+ log?.('meaning', text, scored.map(([s, n]) => items[n].id + ' ' + s.toFixed(2)).join(', '));
72
+ if (s1 >= embedMin && s1 - s2 >= embedGap) return { item: items[n1], by: 'meaning', score: +s1.toFixed(3) };
73
+ // close between the best few: the model decides, or (without one) the player is asked
74
+ if (s1 >= embedMin) unsure = scored.filter(([s]) => s >= embedMin && s1 - s < embedGap).slice(0, 3).map(([, n]) => items[n]);
75
+ }
76
+ } catch (e) { log?.('embedding failed', e); }
77
+ }
78
+ // a language model picks a number
79
+ if (choose) {
80
+ const p = await choose(menu.text || menu.title || 'Which choice?', items.map(i => i.label), { context: 'The player said: "' + String(text).slice(0, 300) + '"' });
81
+ if (p && p.index >= 0) return { item: items[p.index], by: p.by || 'model', score: p.confidence ?? null };
82
+ return { none: true, by: p ? p.by || 'model' : 'no match', ...(unsure && !p ? { unsure } : {}) };
83
+ }
84
+ return unsure ? { none: true, by: 'unsure', unsure } : { none: true, by: 'no match' };
85
+ }
86
+
87
+ // A chooser from any chat model: generate(messages, { maxTokens, temperature })
88
+ // resolves the reply's text. It asks for one number, greedily.
89
+ // choose(question, options, { context, none = true }) -> { index (-1: none), by } | null on failure
90
+ export function modelChooser(generate, { log = null } = {}) {
91
+ return async function choose(question, options, { context = '', none = true } = {}) {
92
+ if (!options?.length) return null;
93
+ const list = options.map((o, n) => `${n + 1}. ${o}`).join('\n') + (none ? '\n0. None of these' : '');
94
+ const prompt = `${context ? context + '\n\n' : ''}${question}\nChoices:\n${list}\n\nWhich choice is meant? Answer with the number only.`;
95
+ try {
96
+ const out = String(await generate([
97
+ { role: 'system', content: 'You match what a player said to one choice of a game menu. Answer with one number and nothing else.' },
98
+ { role: 'user', content: prompt }], { maxTokens: 6, temperature: 0 }) || '');
99
+ log?.('model', JSON.stringify(out));
100
+ const m = out.match(/\d+/);
101
+ if (!m) return { index: -1, by: 'model (no number)' };
102
+ const n = +m[0];
103
+ return { index: n >= 1 && n <= options.length ? n - 1 : -1, by: 'model' };
104
+ } catch (e) { log?.('model failed', e); return null; }
105
+ };
106
+ }
107
+
108
+ export { createMenuBox, injectMenuStyles } from './box.js';
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@tablefolk/menus",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "JRPG-style menus: click, press a number, type or say a choice; words matched by number, label, keywords, meaning (embeddings) or a language model.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./*": "./*"
10
+ },
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/runvnc/tablefolk.git",
15
+ "directory": "libs/menus"
16
+ },
17
+ "homepage": "https://github.com/runvnc/tablefolk/tree/main/libs/menus",
18
+ "keywords": [
19
+ "tablefolk",
20
+ "browser",
21
+ "menu",
22
+ "game",
23
+ "rpg",
24
+ "speech",
25
+ "llm"
26
+ ],
27
+ "sideEffects": false
28
+ }