Compare commits

..
10 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 47d7f9d2c3 Release 0.3.5
Publish release / publish (push) Successful in 1m27s
Test / test (push) Successful in 22s
2026-08-30 11:40:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 12e9fcd609 Gesture widget: ghost path back, for v2 keys only
Test / test (push) Successful in 23s
The relay's ack path is drawn under the stroke when the key's version
nibble is >= 2 (ADR-0014's ordered turning chain decodes to the
stroke itself); a v1 key's blob stays hidden. Safe to ship ahead of
the relay: nothing shows until the relay encodes v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:38:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 72674afe05 Gesture widget: stop drawing the decoded ghost path
Test / test (push) Successful in 25s
decode_key's reconstruction is too rough to have value on screen;
the ack's key is kept, its path ignored. Revisit only once the
reconstruction is fixed at the core in redoal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:21:20 +02:00
Bendik Aagaard Lynghaug 7ba9b08d76 Release 0.3.4
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m27s
2026-08-25 20:25:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1a45839699 Alternative.disabled: announced but not yet takeable
Test / test (push) Successful in 24s
The button renders disabled (title 'Not yet') and submit_answer
refuses the alternative server-side - lysbue's disabled flag, back as
content, for advertising a path before it works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:25:49 +02:00
Bendik Aagaard Lynghaug be54f54804 Release 0.3.3
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m26s
2026-08-25 20:09:00 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8212477377 Empty list says so before it can parse as zero answers
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:08:59 +02:00
Bendik Aagaard Lynghaug e97470c2f6 Release 0.3.2
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 40s
2026-08-25 20:04:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9f26203ed5 Resource empty text: content-declared, and null counts as empty
Test / test (push) Successful in 25s
A `.[] | {...}` jq over an empty list yields no output, which arrived
as null and rendered nothing - redoal's Releases feature was a bare
heading. Null now reads as the same silence as [], and ResourceSpec
gains `empty:` so content can word it ("Nothing released yet");
default stays "Nothing here yet."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:04:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d6fdcd0bce README: hashed pkg assets in the release flow
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:52:31 +02:00
7 changed files with 66 additions and 15 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.1"
version = "0.3.5"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.1"
version = "0.3.5"
edition = "2021"
[lib]
+4 -1
View File
@@ -105,7 +105,10 @@ cargo release patch # or minor / major
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`site/`) to the Gitea release. Plain pushes to `main` only run the
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
**Roll it out** (in a content repo): edit one line in that repo's
+12 -8
View File
@@ -12,8 +12,10 @@
//
// When a relay URL is given, the widget also speaks the redoal-relay
// protocol (ADR-0013 in the redoal repo): announce the stroke, receive
// an ack carrying the stroke's gesture key + the key's own decoded
// path ("what the network heard", drawn as a ghost), and receive
// an ack carrying the stroke's gesture key plus the key's own decoded
// path - "what the network heard", drawn as a ghost under the stroke
// when the key is v2 or later (ADR-0014's ordered turning chain; a v1
// key's histogram decode is a blob and stays hidden) - and receive
// echoes of similar strokes other visitors drew, shown as thumbnails.
// Everything network is best-effort: no relay, refused connection, or
// a dropped socket all degrade to a plain offline drawing input.
@@ -26,13 +28,13 @@
const THEMES = {
dark: {
stroke: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
echo: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
},
light: {
stroke: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
echo: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
},
};
@@ -46,8 +48,8 @@ class GestureWidget {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.points = [];
this.ghost = null;
this.points = [];
this.drawing = false;
this.stopped = false;
this.ws = null;
@@ -111,10 +113,10 @@ class GestureWidget {
e.preventDefault();
this.canvas.setPointerCapture(e.pointerId);
// One stroke only - a new pointerdown replaces the old drawing
// (and any ghost/ambient trails from the previous round).
// (and any ghost from the previous round).
this.drawing = true;
this.points = [this.pos(e)];
this.ghost = null;
this.points = [this.pos(e)];
this.render();
}
@@ -215,7 +217,9 @@ class GestureWidget {
if (this.points.length >= 2) {
this.setValue({ points: this.normalized(), key: msg.key });
}
this.ghost = msg.path;
// 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.addEchoThumbnail(msg);
+30 -4
View File
@@ -900,6 +900,7 @@ fn AlternativeCard(
alternative=alt_name.clone()
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
empty=spec.empty.clone()
pending_transitions=pending_transitions
transition_batch=transition_batch
/>
@@ -1176,7 +1177,17 @@ fn AlternativeCard(
children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/>
</div>
{if is_gateway {
{if alternative.disabled {
// Advertised, not yet takeable: the label
// stays (it says what will be possible),
// the button doesn't act.
view! {
<button type="button" class="alt-submit soon" disabled=true title="Not yet">
{button_label.clone()}
</button>
}
.into_any()
} else if is_gateway {
let href = alternative.action.clone().unwrap_or_default();
view! {
<a class="alt-submit" href=href>{button_label.clone()}</a>
@@ -1219,6 +1230,7 @@ fn ResourceFeature(
alternative: String,
feature_name: String,
transitions: Vec<Transition>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
@@ -1252,6 +1264,7 @@ fn ResourceFeature(
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let empty = empty.clone().unwrap_or_else(|| "Nothing here yet.".to_string());
data.get()
.map(|res| match res {
Ok(value) => {
@@ -1259,6 +1272,7 @@ fn ResourceFeature(
<ResourceValue
feature_name=feature_name
transitions=transitions
empty=empty
value=value
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -1285,11 +1299,23 @@ fn ResourceFeature(
fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
empty: String,
value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
// A jq like `.[] | {...}` over an empty list yields no output at
// all - that arrives as null, and it's the same silence as [].
if value.is_null() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let serde_json::Value::Array(items) = &value {
// Before the Answer parse below: an empty list deserializes as
// an (empty) Vec<Answer> too, and rendered as a blank
// answer-list instead of saying anything.
if items.is_empty() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
answers.sort_by(|a, b| {
@@ -1322,9 +1348,6 @@ fn ResourceValue(
}
.into_any();
}
if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
}
// A list of plain objects (e.g. a jq-shaped GiteaStarred/Url
// resource) - render as cards rather than dumping raw JSON.
// Generic on purpose, no content-declared "kind": `name`/`title`
@@ -1847,6 +1870,9 @@ pub async fn submit_answer(
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?;
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();
+11
View File
@@ -111,6 +111,11 @@ pub struct Alternative {
pub description: String,
#[serde(default)]
pub action: Option<String>,
/// Shown but not takeable yet - the button renders disabled and the
/// server refuses the submission. For advertising a path before it
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub consequence: Vec<String>,
#[serde(default)]
@@ -202,6 +207,12 @@ pub struct ResourceSpec {
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
#[serde(default)]
pub jq: Option<String>,
/// What to say when the resource yields nothing - an empty list,
/// or `null` (a `.[] | ...` jq over an empty list produces no
/// output at all). Defaults to "Nothing here yet."; content sets
/// it where the silence needs a voice ("Nothing released yet").
#[serde(default)]
pub empty: Option<String>,
}
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
+7
View File
@@ -675,6 +675,13 @@ textarea:focus {
cursor: wait;
}
/* A content-disabled alternative (`disabled: true`): announced, not
yet takeable - reads as a promise, not as a stuck form. */
.alt-submit.soon:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.gate-card {
text-align: center;
}