diff --git a/gesture.js b/gesture.js index bef2a65..5ef6c8e 100644 --- a/gesture.js +++ b/gesture.js @@ -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 diff --git a/src/app.rs b/src/app.rs index 793a4b9..13e24b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1999,11 +1999,18 @@ pub async fn submit_answer( if alt.disabled { return Err(ServerFnError::new("this alternative isn't open yet")); } - let next = alt.action.clone(); let record_as = alt.record_as.clone(); let responses: serde_json::Value = serde_json::from_str(&responses_json) .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?; + // `/shape/{curve.key}`: the next page may be chosen by the answer. + let next = match alt.action.as_deref() { + Some(action) => Some( + crate::content::template_action(action, &responses) + .ok_or_else(|| ServerFnError::new("this answer is missing what the next page needs"))?, + ), + None => None, + }; let parent_hashes: Vec = parent_hash.into_iter().collect(); let timestamp_ms = chrono::Utc::now().timestamp_millis(); diff --git a/src/content.rs b/src/content.rs index 8855be4..020b266 100644 --- a/src/content.rs +++ b/src/content.rs @@ -136,6 +136,43 @@ impl Question { } } +/// An alternative's `action` may carry `{field}` / `{field.sub}` +/// placeholders filled from the submitted responses - `/shape/{curve.key}` +/// lands on the dynamic page `/shape/[key]` for the key the visitor's +/// stroke was given. Returns `None` when a placeholder has no value +/// (or a non-scalar one) in `responses`. +pub fn template_action(action: &str, responses: &serde_json::Value) -> Option { + let mut out = String::new(); + let mut rest = action; + while let Some(start) = rest.find('{') { + out.push_str(&rest[..start]); + let end = rest[start..].find('}')? + start; + let path = &rest[start + 1..end]; + let mut value = responses; + for seg in path.split('.') { + value = value.get(seg)?; + } + let scalar = match value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + _ => return None, + }; + if scalar.is_empty() { + return None; + } + // A URL segment: keep it to what a segment can carry. + let safe: String = scalar + .chars() + .map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' }) + .collect(); + out.push_str(&safe); + rest = &rest[end + 1..]; + } + out.push_str(rest); + Some(out) +} + /// Descriptions are inline markdown: `[text](https://…)` links, /// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is /// flattened (a description is one paragraph) and raw HTML in the @@ -1053,6 +1090,26 @@ pub fn validate_questions( // A dangling action is a literal dead end: the submit // button navigates to "Nothing here". if let Some(action) = &alternative.action { + if action.contains('{') { + // Placeholders are filled at submit time; the + // shape must land on a dynamic page once filled. + let probe = template_action(action, &serde_json::json!({})); + let filled = { + let mut a = action.clone(); + while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) { + a.replace_range(i..=j, "x"); + } + a + }; + let _ = probe; + match resolve_question(questions, &filled) { + Some(target) if target.is_dynamic() => continue, + _ => anyhow::bail!( + "question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)", + question.id, alternative.name, action + ), + } + } match questions.get(action) { None => anyhow::bail!( "question {:?} alternative {:?}: action {:?} does not match any declared question id", @@ -1950,4 +2007,16 @@ alternatives: assert_eq!(render_inline_markdown("[ok](/shape)"), "ok"); assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "mail"); } + + #[test] + fn action_templates_from_responses() { + let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3}); + assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98")); + assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y")); + assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape")); + assert!(template_action("/shape/{curve.missing}", &r).is_none()); + assert!(template_action("/shape/{curve.points}", &r).is_none()); + let odd = serde_json::json!({"k": "a/b c"}); + assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c")); + } } diff --git a/style/main.css b/style/main.css index 5552094..5abc08a 100644 --- a/style/main.css +++ b/style/main.css @@ -597,6 +597,34 @@ textarea:focus { opacity: var(--echo-strength, 1); } +/* places are pickable: the pick re-derives the input's key */ +.gesture-echo { + cursor: pointer; + transition: opacity 0.5s ease, border-color 120ms, transform 120ms; +} + +.gesture-echo:hover, +.gesture-echo:focus-visible { + border-color: var(--accent); + outline: none; +} + +.gesture-echo.selected { + border-color: var(--accent); + box-shadow: 0 0 0 0.12rem var(--accent); + transform: translateY(-0.1rem); +} + +.gesture-empty { + margin: 0.5rem 0 0; + font-size: 0.85rem; + color: var(--ink-dim); +} + +.gesture-empty:empty { + display: none; +} + .gesture-status { position: absolute; top: 0.55rem;