Compare commits

..
6 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 b08e1af139 Release 0.3.26
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c1cdc53187 Select fields: inline static options
A select requirement can now declare a plain options: [A, B, C] list
instead of a resource - each string is both value and label. Restores
the ancestor question format's inline enums (used by the klingenberg
port) without needing a resource endpoint for a fixed list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 4fee02ab82 Release 0.3.25
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 405bb98185 Content-shipped stylesheet override (site.yaml stylesheet)
A content repo can now ship a CSS file named in site.yaml's
stylesheet field; portal serves it same-origin at /site/<path> and
leptos_meta appends it after the default stylesheet so content rules
win. Plain repo-relative .css path only, validated on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7fd40aaca9 Release 0.3.24
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:01:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 de6a90bbf5 Gateway submit-as-link loses the underline
A no-requirements gateway alternative renders .alt-submit as an <a>
for plain navigation; strip the link underline so it reads as the
button it's styled to be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:01:28 +02:00
6 changed files with 52 additions and 5 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.3.23" version = "0.3.25"
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.23" version = "0.3.26"
edition = "2021" edition = "2021"
[lib] [lib]
+13 -1
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, MetaTags, Title}; use leptos_meta::{provide_meta_context, HashedStylesheet, Html, MetaTags, Stylesheet, Title};
use leptos_router::{ use leptos_router::{
components::{Route, Router, Routes}, components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map}, hooks::{use_location, use_navigate, use_query_map},
@@ -79,8 +79,20 @@ pub fn App() -> impl IntoView {
}); });
provide_context(Lang(lang)); provide_context(Lang(lang));
// A content-shipped stylesheet (site.yaml `stylesheet:`) layered
// after the default one - leptos_meta appends it at the MetaTags
// slot, which the shell places after HashedStylesheet, so content
// rules win at equal specificity.
let custom_css = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.stylesheet)
.map(|p| format!("/site/{p}"))
});
view! { view! {
<Html attr:lang=move || lang.get()/> <Html attr:lang=move || lang.get()/>
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
{move || { {move || {
site.get() site.get()
+23 -2
View File
@@ -446,6 +446,12 @@ pub struct Requirement {
/// `ResourceSpec` mechanism a `Feature.resource` uses. /// `ResourceSpec` mechanism a `Feature.resource` uses.
#[serde(default)] #[serde(default)]
pub resource: Option<ResourceSpec>, pub resource: Option<ResourceSpec>,
/// `type: select` only - a static list of options, as an
/// alternative to a `resource`. Each string is both the option's
/// stored value and its label. Faithful to the ancestor format's
/// inline enums.
#[serde(default)]
pub options: Vec<String>,
/// `type: select` only - which field in each item is the option's /// `type: select` only - which field in each item is the option's
/// stable id. Defaults to trying `_id` then `id`. /// stable id. Defaults to trying `_id` then `id`.
#[serde(default)] #[serde(default)]
@@ -527,6 +533,11 @@ pub struct SiteConfig {
/// `None` means "en"; unknown codes fall back to English per key. /// `None` means "en"; unknown codes fall back to English per key.
#[serde(default)] #[serde(default)]
pub lang: Option<String>, pub lang: Option<String>,
/// A content-repo-relative CSS file (e.g. "custom.css") layered
/// *after* the default stylesheet, so it overrides. Served
/// same-origin at `/site/<path>`; plain path only.
#[serde(default)]
pub stylesheet: Option<String>,
#[serde(default)] #[serde(default)]
pub hero: HeroConfig, pub hero: HeroConfig,
} }
@@ -582,6 +593,13 @@ impl SiteConfig {
} }
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"), other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
} }
if let Some(sheet) = &self.stylesheet {
if !is_safe_site_path(sheet) || !sheet.ends_with(".css") {
anyhow::bail!(
"site.yaml: stylesheet {sheet:?} must be a plain repo-relative .css path"
);
}
}
Ok(()) Ok(())
} }
} }
@@ -1192,9 +1210,12 @@ pub fn validate_questions(
.collect(); .collect();
for feature in &alternative.features { for feature in &alternative.features {
for requirement in &feature.requirements { for requirement in &feature.requirements {
if requirement.kind == "select" && requirement.resource.is_none() { if requirement.kind == "select"
&& requirement.resource.is_none()
&& requirement.options.is_empty()
{
anyhow::bail!( anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares no resource to select from", "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
question.id, alternative.name, feature.name, requirement.name question.id, alternative.name, feature.name, requirement.name
); );
} }
+10
View File
@@ -68,6 +68,16 @@ pub async fn get_requirement_options(
.iter() .iter()
.find(|r| r.name == requirement_name) .find(|r| r.name == requirement_name)
.ok_or_else(|| ServerFnError::new("unknown requirement"))?; .ok_or_else(|| ServerFnError::new("unknown requirement"))?;
// Inline options: return them as {id,label} objects, the shape the
// SelectField renders, without touching a resource.
if !requirement.options.is_empty() {
let items: Vec<serde_json::Value> = requirement
.options
.iter()
.map(|o| serde_json::json!({ "id": o, "label": o }))
.collect();
return Ok(serde_json::Value::Array(items));
}
let resource = requirement let resource = requirement
.resource .resource
.as_ref() .as_ref()
+4
View File
@@ -861,6 +861,10 @@ textarea:focus {
padding: 0.75rem 1.4rem; padding: 0.75rem 1.4rem;
cursor: pointer; cursor: pointer;
transition: filter 120ms, transform 120ms; transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST);
kill the link chrome so it reads as the button it looks like. */
text-decoration: none;
display: inline-block;
} }
.alt-submit:hover { .alt-submit:hover {