diff --git a/src/app.rs b/src/app.rs
index ea9d391..28454ae 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -230,7 +230,9 @@ fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoVi
view! {
- "Asked by " {responsible.name.clone()} " — "
+ "Asked by " {responsible.name.clone()}
+
+ "— "
"contact them"
" if you get stuck."
{move || {
@@ -834,6 +836,7 @@ fn ResourceFeature(
feature_name=feature_name
transitions=transitions
value=value
+ data=data
/>
}
.into_any()
@@ -860,6 +863,7 @@ fn ResourceValue(
feature_name: String,
transitions: Vec,
value: serde_json::Value,
+ data: Resource>,
) -> impl IntoView {
if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::>(value.clone()) {
@@ -886,6 +890,7 @@ fn ResourceValue(
feature_name=feature_name.clone()
transitions=transitions.clone()
answer=answer
+ data=data
/>
}
.into_any()
@@ -1127,6 +1132,7 @@ fn AnswerRow(
feature_name: String,
transitions: Vec,
answer: Answer,
+ data: Resource>,
) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE;
let responses = answer
@@ -1139,6 +1145,19 @@ fn AnswerRow(
let item_id = answer.id.clone();
let transition = ServerAction::::new();
+ // Toggle-select first (which transition, if any) - nothing is sent
+ // to the server until Confirm is pressed. Previously each button
+ // fired transition_answer immediately on click with no staging
+ // step and no visible confirmation once it landed (the list never
+ // refetched), so a click barely looked like it did anything.
+ let selected: RwSignal> = RwSignal::new(None);
+
+ Effect::new(move |_| {
+ if let Some(Ok(())) = transition.value().get() {
+ selected.set(None);
+ data.refetch();
+ }
+ });
view! {
@@ -1170,44 +1189,64 @@ fn AnswerRow(
move || is_open && !transitions.is_empty()
}>
-
+
{t.label.clone()}
}
.into_any()
}
+ />
+
+
+ >
+ {move || if transition.pending().get() { "Confirming…" } else { "Confirm" }}
+
diff --git a/src/resource.rs b/src/resource.rs
index a6da68e..a25881b 100644
--- a/src/resource.rs
+++ b/src/resource.rs
@@ -133,13 +133,19 @@ async fn fetch_resource_value(
let value = match &resource.source {
ResourceSource::Kv { bucket } => {
- let store = state
- .jetstream
- .get_key_value(bucket)
- .await
- .map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
- match &resource.key {
- Some(key) => {
+ // A bucket only ever gets created on first write
+ // (answers::store_answer's own create-on-first-use, or the
+ // event-sourcing projection upsert) - a bucket declared in
+ // content but never yet written to is a completely normal
+ // "nothing submitted here yet" state, not an error. Listing
+ // it reads the same as an empty bucket would; asking for
+ // one specific key that can't possibly exist yet still
+ // surfaces as an error, same as key-not-found.
+ let store = state.jetstream.get_key_value(bucket).await.ok();
+ match (&resource.key, store) {
+ (Some(_), None) => return Err(ServerFnError::new("resource key not found")),
+ (None, None) => serde_json::Value::Array(Vec::new()),
+ (Some(key), Some(store)) => {
let bytes = store
.get(key)
.await
@@ -147,7 +153,7 @@ async fn fetch_resource_value(
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?
}
- None => {
+ (None, Some(store)) => {
use futures::TryStreamExt;
let keys: Vec = store
.keys()
diff --git a/style/main.css b/style/main.css
index 7244669..a2ae5f2 100644
--- a/style/main.css
+++ b/style/main.css
@@ -153,6 +153,22 @@ main.loading, main.not-found {
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.8);
}
+/* Softens the otherwise hard cut where the full-bleed canvas meets the
+ page background right below it - fades to the same --paper color
+ over the last few ems instead of stopping dead. Non-interactive so
+ it never steals clicks meant for the canvas underneath. */
+.hero-yes::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 6em;
+ background: linear-gradient(to bottom, transparent, var(--paper));
+ pointer-events: none;
+ z-index: 1;
+}
+
/* alternatives */
.alternatives {
@@ -163,6 +179,29 @@ main.loading, main.not-found {
gap: 1.25rem;
}
+.question-responsible {
+ text-align: center;
+ color: var(--ink-dim);
+ font-size: 0.85rem;
+ line-height: 1.6;
+}
+
+.question-responsible a { color: var(--ink-dim); }
+.question-responsible a:hover { color: var(--accent); }
+
+.question-report {
+ font: inherit;
+ color: var(--ink-dim);
+ background: none;
+ border: none;
+ padding: 0;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.question-report:hover { color: var(--accent); }
+.question-report:disabled { opacity: 0.5; cursor: wait; }
+
.alt-card {
background: var(--paper-raised);
border: 1px solid var(--line);
@@ -364,23 +403,28 @@ input:focus, textarea:focus {
color: var(--accent);
}
-.answer-actions { display: flex; gap: 0.6rem; }
+.answer-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.6rem;
+}
-.answer-action {
+.answer-confirm {
font-family: var(--sans);
font-weight: 600;
font-size: 0.82rem;
- color: var(--ink);
- background: var(--paper);
- border: 1px solid var(--line);
+ color: #0a0a0a;
+ background: var(--accent);
+ border: none;
border-radius: 0.5rem;
- padding: 0.4rem 0.8rem;
+ padding: 0.45rem 1rem;
cursor: pointer;
- transition: border-color 120ms, color 120ms;
+ transition: filter 120ms;
}
-.answer-action:hover { border-color: var(--accent); color: var(--accent); }
-.answer-action:disabled { opacity: 0.5; cursor: wait; }
+.answer-confirm:hover { filter: brightness(1.08); }
+.answer-confirm:disabled { opacity: 0.5; cursor: not-allowed; filter: none; }
.item-list { display: grid; gap: 0.8rem; }