7 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 703e6b6356 Release 0.3.12
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m32s
2026-08-30 16:06:58 +02:00
Bendik Aagaard Lynghaug 2c545cd1b0 Voice preview stays hidden until there is something to play 2026-08-30 16:06:57 +02:00
Bendik Aagaard Lynghaug 47c894cc1d Release 0.3.11
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m32s
2026-08-30 16:02:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7bc59a65e0 Validate templated actions against the page pattern
resolve_question returns a clone with a concrete id, so is_dynamic()
on it was always false and every templated action failed lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:02:41 +02:00
Bendik Aagaard Lynghaug 243b6a5704 README: gesture and voice fields
Test / test (push) Successful in 24s
2026-08-30 16:00:25 +02:00
Bendik Aagaard Lynghaug 3a20870762 Release 0.3.10
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m34s
2026-08-30 15:58:05 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 07776be2bb type: voice, Requirement.value, playable resource cards
A voice requirement records in the browser and ships PCM to the relay
as one binary frame (voice.js, same mount/stop contract as gesture);
its value becomes {key, digest, duration_ms}. Requirement.value
presets a field and, on a dynamic page, takes the URL segment - so
value: "{key}" tells the voice field where to record. A url resource
source takes the segment too. Resource items with an https `audio`
field render an <audio> player. The gesture widget reports picks to
the relay for ranking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:58:04 +02:00
8 changed files with 386 additions and 14 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.3.9" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.3.9" version = "0.3.12"
edition = "2021" edition = "2021"
[lib] [lib]
+10
View File
@@ -39,6 +39,16 @@ stream.
`requires_chain` gates a page on verifiable answer provenance next `requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`). `docs/design/filesystem-routes.md`).
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
redoal repo) - the input shows the key's own decode under the
stroke, who is at a similar shape now, and *places* with recordings
the visitor can pick to make that key theirs; a voice field records
in the browser and leaves the audio at a key. Actions may be
templated from the answer (`/shape/{curve.key}`) onto dynamic
pages, whose URL segment also fills `value: "{key}"` presets and
`url` resource sources; resource items with an https `audio` field
render a player.
- **Announced pages** (`src/announce.rs`): a question with an - **Announced pages** (`src/announce.rs`): a question with an
`event: {starts, duration, place}` window is announced in the `event: {starts, duration, place}` window is announced in the
header (not the footer nav) while the window is open, becomes a header (not the footer nav) while the window is open, becomes a
+4
View File
@@ -274,6 +274,10 @@ class GestureWidget {
this.selected = null; this.selected = null;
} else { } else {
this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) }; this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) };
// The relay counts picks per place for ranking.
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownKey) {
this.ws.send(JSON.stringify({ type: 'select', key, from: this.ownKey, distance: this.selected.distance }));
}
} }
this.applySelection(); this.applySelection();
} }
+81 -3
View File
@@ -498,6 +498,28 @@ mod prosekit {
// prosekit's hidden-input bridge for the value, yes.js's typed-handle // prosekit's hidden-input bridge for the value, yes.js's typed-handle
// lifecycle for cleanup - it may own a live WebSocket to a // lifecycle for cleanup - it may own a live WebSocket to a
// redoal-relay, which SPA navigation must close (`stop()`). // redoal-relay, which SPA navigation must close (`stop()`).
#[cfg(feature = "hydrate")]
#[cfg(feature = "hydrate")]
mod voice {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/voice.js")]
extern "C" {
pub type VoiceWidget;
#[wasm_bindgen(js_name = mountVoice)]
pub fn mount_voice(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
relay_url: &str,
key: &str,
) -> VoiceWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &VoiceWidget);
}
}
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
mod gesture { mod gesture {
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@@ -766,12 +788,16 @@ fn AlternativeCard(
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(Vec::new()));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
if req.kind == "gesture" { // Widget-owned fields hold JSON objects, not strings.
if req.kind == "gesture" || req.kind == "voice" {
gesture_fields.insert(req.name.clone()); gesture_fields.insert(req.name.clone());
} }
// A voice field's preset is the key it records at,
// not a value to submit.
let preset = if req.kind == "voice" { String::new() } else { req.value.clone().unwrap_or_default() };
field_map field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new())); .or_insert_with(|| RwSignal::new(preset));
} }
} }
} }
@@ -1138,6 +1164,54 @@ fn AlternativeCard(
.into_any(); .into_any();
} }
if req.kind == "voice" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
#[cfg(feature = "hydrate")]
{
let relay = req.relay.clone().unwrap_or_default();
let key = req.value.clone().unwrap_or_default();
let widget: StoredValue<Option<voice::VoiceWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(voice::mount_voice(
&container, &hidden, &relay, &key,
)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
return view! {
<div class="field">
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="voice-wrap" node_ref=container_ref></div>
</div>
}
.into_any();
}
if req.kind == "prosekit" { if req.kind == "prosekit" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
@@ -1454,8 +1528,11 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
let name = text_field(&obj, &["name", "title"]); let name = text_field(&obj, &["name", "title"]);
let description = text_field(&obj, &["description"]); let description = text_field(&obj, &["description"]);
let url = text_field(&obj, &["url", "html_url"]); let url = text_field(&obj, &["url", "html_url"]);
// A playable recording (an https URL to audio) renders as a
// player, not a link - the relay's /blob/<digest>.wav.
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
let known = ["name", "title", "description", "url", "html_url"]; let known = ["name", "title", "description", "url", "html_url", "audio"];
let extra: Vec<(String, String)> = obj let extra: Vec<(String, String)> = obj
.iter() .iter()
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null()) .filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null())
@@ -1495,6 +1572,7 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
} }
})} })}
{description.map(|d| view! { <p class="item-card-description">{d}</p> })} {description.map(|d| view! { <p class="item-card-description">{d}</p> })}
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
{(!extra.is_empty()) {(!extra.is_empty())
.then(|| { .then(|| {
view! { view! {
+70 -8
View File
@@ -439,7 +439,13 @@ pub struct Requirement {
/// the selected file's current content. /// the selected file's current content.
#[serde(default)] #[serde(default)]
pub bind: Option<Bind>, pub bind: Option<Bind>,
/// `type: gesture` only - ws(s):// URL of a redoal-relay instance /// A preset value the field starts with. On a dynamic page the
/// URL segment substitutes into `{name}` placeholders here, like
/// resource keys - `value: "{key}"` on a hidden field carries the
/// page's key into the answer.
#[serde(default)]
pub value: Option<String>,
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
/// the drawing widget connects to for live echoes of similar /// the drawing widget connects to for live echoes of similar
/// strokes. Absent means the widget works offline: the drawn path /// strokes. Absent means the widget works offline: the drawn path
/// still submits, it just never gets a network-computed key or /// still submits, it just never gets a network-computed key or
@@ -817,15 +823,26 @@ pub fn resolve_question(
} }
} }
}; };
// A url source's address takes the segment too:
// `https://relay.redoal.com/place/{key}`.
let substitute_url = |src: &mut ResourceSource| {
if let ResourceSource::Url { url } = src {
for (name, value) in &captures {
*url = url.replace(&format!("{{{name}}}"), value);
}
}
};
for alt in &mut resolved.alternatives { for alt in &mut resolved.alternatives {
for feature in &mut alt.features { for feature in &mut alt.features {
if let Some(res) = &mut feature.resource { if let Some(res) = &mut feature.resource {
substitute(&mut res.key); substitute(&mut res.key);
substitute_url(&mut res.source);
} }
for req in &mut feature.requirements { for req in &mut feature.requirements {
if let Some(res) = &mut req.resource { if let Some(res) = &mut req.resource {
substitute(&mut res.key); substitute(&mut res.key);
} }
substitute(&mut req.value);
} }
} }
} }
@@ -1102,13 +1119,18 @@ pub fn validate_questions(
a a
}; };
let _ = probe; let _ = probe;
match resolve_question(questions, &filled) { // Match the pattern itself: resolve_question would
Some(target) if target.is_dynamic() => continue, // hand back a clone whose id is already concrete.
_ => anyhow::bail!( let lands = questions
.values()
.any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some());
if lands {
continue;
}
anyhow::bail!(
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)", "question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)",
question.id, alternative.name, action question.id, alternative.name, action
), );
}
} }
match questions.get(action) { match questions.get(action) {
None => anyhow::bail!( None => anyhow::bail!(
@@ -1151,9 +1173,9 @@ pub fn validate_questions(
); );
} }
if let Some(relay) = &requirement.relay { if let Some(relay) = &requirement.relay {
if requirement.kind != "gesture" { if requirement.kind != "gesture" && requirement.kind != "voice" {
anyhow::bail!( anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture", "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
question.id, alternative.name, feature.name, requirement.name, requirement.kind question.id, alternative.name, feature.name, requirement.name, requirement.kind
); );
} }
@@ -2008,6 +2030,22 @@ alternatives:
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>"); assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
} }
#[test]
fn templated_action_validates_against_the_dynamic_page() {
let landing: Question = serde_yaml::from_str(
"id: /\nname: L\nalternatives:\n - name: A\n action: /shape/{curve.key}\n",
)
.unwrap();
let place: Question = serde_yaml::from_str("id: /shape/[key]\nname: P\n").unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(landing.id.clone(), landing.clone());
qs.insert(place.id.clone(), place);
validate_questions(&qs, &Default::default()).unwrap();
let mut only_landing = std::collections::HashMap::new();
only_landing.insert(landing.id.clone(), landing);
assert!(validate_questions(&only_landing, &Default::default()).is_err());
}
#[test] #[test]
fn action_templates_from_responses() { fn action_templates_from_responses() {
let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3}); let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3});
@@ -2019,4 +2057,28 @@ alternatives:
let odd = serde_json::json!({"k": "a/b c"}); let odd = serde_json::json!({"k": "a/b c"});
assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c")); assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c"));
} }
#[test]
fn dynamic_page_fills_requirement_values() {
let q: Question = serde_yaml::from_str(
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n",
)
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(q.id.clone(), q);
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98"));
let q: Question = serde_yaml::from_str(
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
)
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(q.id.clone(), q);
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
match &page.alternatives[0].features[0].resource.as_ref().unwrap().source {
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
_ => panic!(),
}
}
} }
+56
View File
@@ -448,6 +448,62 @@ main.not-found {
margin-bottom: 0.15em; margin-bottom: 0.15em;
} }
/* voice field (voice.js): a record button, a status line, a preview */
.voice-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.voice-button {
font: inherit;
padding: 0.55rem 1.1rem;
border: 0.06rem solid var(--accent);
border-radius: 2rem;
background: transparent;
color: var(--ink);
cursor: pointer;
}
.voice-button:hover:not(:disabled) {
background: var(--accent);
color: var(--paper);
}
.voice-button.recording {
background: var(--accent);
color: var(--paper);
animation: voice-pulse 1.2s ease-in-out infinite;
}
.voice-button:disabled {
opacity: 0.5;
cursor: default;
}
@keyframes voice-pulse {
50% { opacity: 0.6; }
}
.voice-status {
font-size: 0.85rem;
color: var(--ink-dim);
}
.voice-preview,
.item-card-audio {
display: block;
width: 100%;
max-width: 28rem;
margin-top: 0.4rem;
}
/* display:block above would beat the hidden attribute */
.voice-preview[hidden] {
display: none;
}
/* links inside markdown descriptions */ /* links inside markdown descriptions */
.alt-description a, .alt-description a,
.feature p a { .feature p a {
+162
View File
@@ -0,0 +1,162 @@
// A `type: voice` requirement: leave a recording at a gesture key.
// Same contract as gesture.js - portal mounts it with the field's
// hidden input and the relay URL, and calls stop() on navigation.
//
// The browser records with MediaRecorder (whatever container it
// likes), decodes that to PCM with WebAudio, resamples to mono 48 kHz,
// and ships one binary frame `[0x01][20-byte key][i16le PCM]` to the
// relay, which encodes with the one recording codec, signs as itself,
// keeps it and announces it (ADR-0015). The field's value becomes
// {key, digest, duration_ms} once the relay confirms.
//
// Palette: colors come from the page's custom properties, no copy here.
const MAX_SECONDS = 60;
const SAMPLE_RATE = 48000;
class VoiceWidget {
constructor(container, hidden, relayUrl, key) {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.key = (key || '').trim();
this.stopped = false;
this.recorder = null;
this.stream = null;
this.chunks = [];
this.ws = null;
this.button = document.createElement('button');
this.button.type = 'button';
this.button.className = 'voice-button';
this.button.textContent = 'Record';
this.status = document.createElement('span');
this.status.className = 'voice-status';
this.preview = document.createElement('audio');
this.preview.className = 'voice-preview';
this.preview.controls = true;
this.preview.hidden = true;
container.append(this.button, this.status, this.preview);
this._onClick = () => this.toggle();
this.button.addEventListener('click', this._onClick);
if (!this.key || this.key.length !== 40) {
this.button.disabled = true;
this.say('This page has no address to leave a voice at.');
} else if (!navigator.mediaDevices || !window.MediaRecorder) {
this.button.disabled = true;
this.say('Recording needs a browser with a microphone API.');
}
}
say(text) {
this.status.textContent = text;
}
async toggle() {
if (this.recorder && this.recorder.state === 'recording') {
this.recorder.stop();
return;
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
this.say('Microphone access was declined.');
return;
}
this.chunks = [];
this.recorder = new MediaRecorder(this.stream);
this.recorder.addEventListener('dataavailable', (e) => { if (e.data.size) this.chunks.push(e.data); });
this.recorder.addEventListener('stop', () => this.finish());
this.recorder.start();
this.button.textContent = 'Stop';
this.button.classList.add('recording');
this.say(`Recording — up to ${MAX_SECONDS} seconds.`);
this.capTimer = setTimeout(() => { if (this.recorder && this.recorder.state === 'recording') this.recorder.stop(); }, MAX_SECONDS * 1000);
}
async finish() {
clearTimeout(this.capTimer);
this.button.textContent = 'Record again';
this.button.classList.remove('recording');
if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
const blob = new Blob(this.chunks, { type: this.recorder.mimeType || 'audio/webm' });
if (!blob.size) { this.say('Nothing was recorded.'); return; }
this.preview.src = URL.createObjectURL(blob);
this.preview.hidden = false;
this.say('Sending…');
let pcm;
try {
pcm = await this.toPcm16(await blob.arrayBuffer());
} catch (e) {
this.say('Could not decode the recording.');
return;
}
this.upload(pcm);
}
// Decode → mono 48 kHz Float32 → Int16 little-endian bytes.
async toPcm16(buffer) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const decoded = await ctx.decodeAudioData(buffer);
await ctx.close();
const frames = Math.ceil(decoded.duration * SAMPLE_RATE);
const offline = new OfflineAudioContext(1, frames, SAMPLE_RATE);
const src = offline.createBufferSource();
src.buffer = decoded;
src.connect(offline.destination);
src.start();
const rendered = await offline.startRendering();
const f32 = rendered.getChannelData(0);
const out = new Uint8Array(f32.length * 2);
const view = new DataView(out.buffer);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return out;
}
upload(pcm) {
if (!this.relayUrl) { this.say('No relay to send to.'); return; }
const frame = new Uint8Array(1 + 20 + pcm.length);
frame[0] = 1;
for (let i = 0; i < 20; i++) frame[1 + i] = parseInt(this.key.substr(i * 2, 2), 16);
frame.set(pcm, 21);
const ws = new WebSocket(this.relayUrl);
ws.binaryType = 'arraybuffer';
this.ws = ws;
ws.addEventListener('open', () => ws.send(frame));
ws.addEventListener('message', (e) => {
let msg; try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'published') {
this.setValue({ key: this.key, digest: msg.digest, duration_ms: msg.duration_ms });
this.say(`Kept at this address — ${Math.round(msg.duration_ms / 1000)} s.`);
ws.close();
} else if (msg.type === 'error') {
this.say(msg.message || 'The relay declined the recording.');
ws.close();
}
});
ws.addEventListener('error', () => this.say('The relay could not be reached.'));
}
setValue(value) {
if (!this.hidden) return;
this.hidden.value = JSON.stringify(value);
this.hidden.dispatchEvent(new Event('input', { bubbles: true }));
}
stop() {
this.stopped = true;
clearTimeout(this.capTimer);
if (this.recorder && this.recorder.state === 'recording') this.recorder.stop();
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
if (this.ws) this.ws.close();
this.button.removeEventListener('click', this._onClick);
}
}
export function mountVoice(container, hidden, relayUrl, key) {
return new VoiceWidget(container, hidden, relayUrl, key);
}