Templated actions; places are pickable in the gesture input

action: /shape/{curve.key} fills placeholders from the submitted
responses and must land on a dynamic page (validated). The gesture
widget now treats relay 'place' (kept) and 'echo' (live presence)
frames as pickable options: picking one re-derives the input's key -
that place's decode becomes the ghost under the stroke and the answer
records {key: picked, own_key, selected_from, selected_distance}, the
labelled pair that later ranks places and calibrates the key. Dedupe
by key; an empty state when nothing is kept at the shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-30 15:38:23 +02:00
co-authored by Claude Fable 5
parent ff2f94e549
commit b5ee79da8e
4 changed files with 173 additions and 8 deletions
+68 -7
View File
@@ -49,6 +49,9 @@ class GestureWidget {
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.ghost = null;
this.ownKey = null;
this.selected = null; // {key, path, distance} - a place the visitor picked
this.seenKeys = new Set();
this.points = [];
this.drawing = false;
this.stopped = false;
@@ -62,7 +65,12 @@ class GestureWidget {
container.appendChild(this.canvas);
this.echoes = document.createElement('div');
this.echoes.className = 'gesture-echoes';
this.echoes.setAttribute('role', 'listbox');
this.echoes.setAttribute('aria-label', 'places near your shape');
container.appendChild(this.echoes);
this.empty = document.createElement('p');
this.empty.className = 'gesture-empty';
container.appendChild(this.empty);
if (this.relayUrl) {
this.status = document.createElement('span');
this.status.className = 'gesture-status offline';
@@ -116,6 +124,11 @@ class GestureWidget {
// (and any ghost from the previous round).
this.drawing = true;
this.ghost = null;
this.ownKey = null;
this.selected = null;
this.seenKeys.clear();
this.echoes.replaceChildren();
this.empty.textContent = '';
this.points = [this.pos(e)];
this.render();
}
@@ -214,14 +227,20 @@ class GestureWidget {
return;
}
if (msg.type === 'ack') {
if (this.points.length >= 2) {
this.setValue({ points: this.normalized(), key: msg.key });
}
this.ownKey = msg.key;
// Version nibble is the key's first hex digit.
const version = parseInt((msg.key || '0')[0], 16);
this.ghost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.render();
} else if (msg.type === 'echo') {
this.ownGhost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.applySelection();
// Places arrive right after the ack; say so if none do.
clearTimeout(this.emptyTimer);
this.emptyTimer = setTimeout(() => {
if (!this.echoes.children.length) this.empty.textContent = msg.empty || 'Nothing kept at this shape yet.';
}, 1500);
} else if (msg.type === 'echo' || msg.type === 'place') {
if (!msg.key || this.seenKeys.has(msg.key)) return;
this.seenKeys.add(msg.key);
this.empty.textContent = '';
this.addEchoThumbnail(msg);
}
// 'error' frames are intentionally silent: the widget is a
@@ -229,9 +248,50 @@ class GestureWidget {
// alarm anyone mid-form.
}
// The key the input carries: the visitor's own, or the place they
// picked (selection re-derives the key - the ghost under the
// stroke becomes that place's decode, and the answer records both).
applySelection() {
if (this.points.length < 2) return;
const points = this.normalized();
if (this.selected) {
this.ghost = this.selected.path;
this.setValue({ points, key: this.selected.key, own_key: this.ownKey, selected_from: this.ownKey, selected_distance: this.selected.distance });
} else {
this.ghost = this.ownGhost || null;
this.setValue({ points, key: this.ownKey });
}
for (const el of this.echoes.children) {
el.classList.toggle('selected', !!this.selected && el.dataset.key === this.selected.key);
el.setAttribute('aria-selected', String(!!this.selected && el.dataset.key === this.selected.key));
}
this.render();
}
select(thumb) {
const key = thumb.dataset.key;
if (this.selected && this.selected.key === key) {
this.selected = null;
} else {
this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) };
}
this.applySelection();
}
addEchoThumbnail(msg) {
const thumb = document.createElement('canvas');
thumb.className = 'gesture-echo';
thumb.className = 'gesture-echo' + (msg.type === 'place' ? ' place' : '');
thumb.dataset.key = msg.key;
thumb.dataset.path = JSON.stringify(msg.path || []);
thumb.dataset.distance = String(msg.distance || 0);
thumb.setAttribute('role', 'option');
thumb.setAttribute('tabindex', '0');
thumb.setAttribute('aria-selected', 'false');
thumb.title = msg.type === 'place'
? `A place with ${msg.recordings || 0} recording${msg.recordings === 1 ? '' : 's'} - tap to make it your address`
: 'Someone at this shape right now';
thumb.addEventListener('click', () => this.select(thumb));
thumb.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.select(thumb); } });
// Closer strokes render stronger; 500 is comfortably past the
// relay's default threshold, so everything stays visible.
const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500));
@@ -296,6 +356,7 @@ class GestureWidget {
stop() {
this.stopped = true;
clearTimeout(this.emptyTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
// The close event still fires, but scheduleReconnect