Compare commits

...
9 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 9fca79b8ee Release 0.3.20
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:54:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3d51aa1e6a Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
A content-only instance (westra preview) has no review desk and no
Kanidm client; booting no longer demands one. Auth routes answer 503
'sign-in is not configured on this instance'; everything public
renders as usual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:54:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d5eb362c87 Release 0.3.19
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:47:01 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7f10ba20d3 Markdown images in descriptions, gated and framed
Images already flowed through render_inline_markdown ungated; they
now take the same scheme gate as links (https or same-origin only -
no data:, no plain http) and render as full-width framed figures in
alternative, feature, and item-card descriptions. Carries whole-site
imagery for content-driven instances (westra).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:46:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 756818cacd Release 0.3.18
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 15:58:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0423e9c368 Merge convergence: ink-to-key animation + overlay results
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:58:41 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ea7acf4d7d Gesture results overlay the canvas - the page never jumps
Echo thumbnails float centered along the canvas's bottom edge and the
empty-state line sits in the same band; both are out of flow, so the
widget's height is fixed by the canvas alone whether results come
back or not. The strip is pointer-inert except the thumbnails
themselves, so drawing near the bottom edge still works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:55:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 19f8fbaf03 Convergence: ink converges onto the decoded key (WebGL glow)
On ack (and on picking a place) a glowing copy of the stroke peels
off and converges point-by-point onto the key's decoded path - a
staggered wave from stroke start to end, comet trails, additive on
dark / ink on light, ghost deposited where the light settles. WebGL
point sprites on an overlay canvas; no WebGL or reduced-motion means
exactly the previous behavior. ?relay= query override points widgets
at a local relay for dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:47:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ee295be6a7 Item cards render inline markdown descriptions
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m39s
Resource-fed cards follow the same convention as every other
description: links live there, sanitized by render_inline_markdown.
Carries the per-recording report link on place pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-31 17:18:40 +02:00
7 changed files with 312 additions and 12 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.3.16" version = "0.3.20"
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.16" version = "0.3.20"
edition = "2021" edition = "2021"
[lib] [lib]
+232 -2
View File
@@ -43,6 +43,77 @@ const MAX_ECHOES = 8;
const RECONNECT_BASE_MS = 1000; const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000; const RECONNECT_MAX_MS = 30000;
// ── Convergence animation (ink → what the network heard) ──────────
//
// When the ack lands, a glowing copy of the stroke peels off and
// converges point-by-point onto the key's decoded path: a wave of
// "being understood" travels from the stroke's start to its end,
// and the ghost materializes where the light settles. WebGL point
// sprites with additive glow on a dedicated overlay canvas; the 2d
// layer underneath never changes its final render, so losing WebGL
// (or prefers-reduced-motion) degrades to exactly today's behavior.
const CONVERGE_MS = 1500;
const CONVERGE_FADE_MS = 450;
const CONVERGE_POINTS = 128;
const CONVERGE_STAGGER = 0.6; // fraction of the timeline spent on the travelling wave
const GLOW_VERT = `
attribute vec2 aFrom;
attribute vec2 aTo;
attribute float aIndex;
uniform float uT;
uniform vec2 uResolution;
uniform float uDpr;
varying float vLife;
void main() {
float lead = aIndex * ${CONVERGE_STAGGER};
float p = clamp((uT * ${1.0 + CONVERGE_STAGGER} - lead), 0.0, 1.0);
float e = p < 0.5 ? 4.0 * p * p * p : 1.0 - pow(-2.0 * p + 2.0, 3.0) / 2.0;
vec2 pos = mix(aFrom, aTo, e);
vec2 clip = (pos / uResolution) * 2.0 - 1.0;
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
float pulse = 1.0 + 1.4 * sin(3.14159 * p);
gl_PointSize = 9.0 * uDpr * pulse;
vLife = p;
}`;
const GLOW_FRAG = `
precision mediump float;
uniform vec3 uColor;
uniform float uAlpha;
varying float vLife;
void main() {
vec2 d = gl_PointCoord - 0.5;
float fall = exp(-dot(d, d) * 18.0);
float breathe = 0.55 + 0.45 * sin(3.14159 * vLife);
gl_FragColor = vec4(uColor, 1.0) * (fall * uAlpha * breathe);
}`;
// Uniform arc-length resample of a pixel-space polyline to n points.
function resamplePx(points, n) {
if (points.length === 1) return Array(n).fill(points[0]);
const dists = [0];
for (let i = 1; i < points.length; i++) {
const dx = points[i][0] - points[i - 1][0];
const dy = points[i][1] - points[i - 1][1];
dists.push(dists[i - 1] + Math.hypot(dx, dy));
}
const total = dists[dists.length - 1] || 1;
const out = [];
let seg = 0;
for (let i = 0; i < n; i++) {
const target = (i / (n - 1)) * total;
while (seg < points.length - 2 && dists[seg + 1] < target) seg++;
const span = dists[seg + 1] - dists[seg] || 1;
const t = (target - dists[seg]) / span;
out.push([
points[seg][0] + (points[seg + 1][0] - points[seg][0]) * t,
points[seg][1] + (points[seg + 1][1] - points[seg][1]) * t,
]);
}
return out;
}
class GestureWidget { class GestureWidget {
constructor(container, hidden, relayUrl) { constructor(container, hidden, relayUrl) {
this.container = container; this.container = container;
@@ -63,6 +134,17 @@ class GestureWidget {
this.canvas = document.createElement('canvas'); this.canvas = document.createElement('canvas');
this.canvas.className = 'gesture-canvas'; this.canvas.className = 'gesture-canvas';
container.appendChild(this.canvas); container.appendChild(this.canvas);
// Overlay for the convergence glow; sized with the main canvas,
// inert to pointers, empty until an ack arrives.
this.glow = document.createElement('canvas');
this.glow.className = 'gesture-glow';
this.glow.style.position = 'absolute';
this.glow.style.pointerEvents = 'none';
if (!container.style.position) container.style.position = 'relative';
container.appendChild(this.glow);
this.gl = null;
this.animFrame = null;
this.converging = false;
this.echoes = document.createElement('div'); this.echoes = document.createElement('div');
this.echoes.className = 'gesture-echoes'; this.echoes.className = 'gesture-echoes';
this.echoes.setAttribute('role', 'listbox'); this.echoes.setAttribute('role', 'listbox');
@@ -110,6 +192,13 @@ class GestureWidget {
this.ctx.scale(dpr, dpr); this.ctx.scale(dpr, dpr);
this.cssWidth = rect.width; this.cssWidth = rect.width;
this.cssHeight = rect.height; this.cssHeight = rect.height;
this.glow.style.left = `${this.canvas.offsetLeft}px`;
this.glow.style.top = `${this.canvas.offsetTop}px`;
this.glow.style.width = `${rect.width}px`;
this.glow.style.height = `${rect.height}px`;
this.glow.width = this.canvas.width;
this.glow.height = this.canvas.height;
this.cancelConvergence(); // buffers are in old pixels
} }
pos(e) { pos(e) {
@@ -123,6 +212,7 @@ class GestureWidget {
// One stroke only - a new pointerdown replaces the old drawing // One stroke only - a new pointerdown replaces the old drawing
// (and any ghost from the previous round). // (and any ghost from the previous round).
this.drawing = true; this.drawing = true;
this.cancelConvergence();
this.ghost = null; this.ghost = null;
this.ownKey = null; this.ownKey = null;
this.selected = null; this.selected = null;
@@ -254,6 +344,7 @@ class GestureWidget {
applySelection() { applySelection() {
if (this.points.length < 2) return; if (this.points.length < 2) return;
const points = this.normalized(); const points = this.normalized();
const before = this.ghost;
if (this.selected) { if (this.selected) {
this.ghost = this.selected.path; 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 }); this.setValue({ points, key: this.selected.key, own_key: this.ownKey, selected_from: this.ownKey, selected_distance: this.selected.distance });
@@ -261,6 +352,7 @@ class GestureWidget {
this.ghost = this.ownGhost || null; this.ghost = this.ownGhost || null;
this.setValue({ points, key: this.ownKey }); this.setValue({ points, key: this.ownKey });
} }
if (this.ghost && this.ghost !== before) this.startConvergence(this.ghost);
for (const el of this.echoes.children) { for (const el of this.echoes.children) {
el.classList.toggle('selected', !!this.selected && el.dataset.key === this.selected.key); 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)); el.setAttribute('aria-selected', String(!!this.selected && el.dataset.key === this.selected.key));
@@ -317,6 +409,140 @@ class GestureWidget {
setTimeout(() => thumb.classList.add('shown'), 30); setTimeout(() => thumb.classList.add('shown'), 30);
} }
// ── Convergence ────────────────────────────────────────────────
initGlow() {
if (this.gl) return this.gl;
const gl = this.glow.getContext('webgl', { alpha: true, premultipliedAlpha: true });
if (!gl) return null;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, GLOW_VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, GLOW_FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
this.gl = gl;
this.glProg = prog;
this.glLoc = {
aFrom: gl.getAttribLocation(prog, 'aFrom'),
aTo: gl.getAttribLocation(prog, 'aTo'),
aIndex: gl.getAttribLocation(prog, 'aIndex'),
uT: gl.getUniformLocation(prog, 'uT'),
uResolution: gl.getUniformLocation(prog, 'uResolution'),
uDpr: gl.getUniformLocation(prog, 'uDpr'),
uColor: gl.getUniformLocation(prog, 'uColor'),
uAlpha: gl.getUniformLocation(prog, 'uAlpha'),
};
this.glBufFrom = gl.createBuffer();
this.glBufTo = gl.createBuffer();
this.glBufIndex = gl.createBuffer();
return gl;
}
// The ghost's pixel frame - same fit drawPath uses.
ghostToPx(path) {
const scale = (Math.min(this.cssWidth, this.cssHeight) / 2) * 0.8;
const cx = this.cssWidth / 2, cy = this.cssHeight / 2;
return path.map(([x, y]) => [cx + x * scale, cy + y * scale]);
}
startConvergence(targetPath) {
this.cancelConvergence();
if (!targetPath || this.points.length < 2) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const gl = this.initGlow();
if (!gl) return;
const dpr = window.devicePixelRatio || 1;
const from = resamplePx(this.points.map((p) => [p.x, p.y]), CONVERGE_POINTS);
const to = resamplePx(this.ghostToPx(targetPath), CONVERGE_POINTS);
const f = new Float32Array(CONVERGE_POINTS * 2);
const t = new Float32Array(CONVERGE_POINTS * 2);
const idx = new Float32Array(CONVERGE_POINTS);
for (let i = 0; i < CONVERGE_POINTS; i++) {
f[i * 2] = from[i][0] * dpr;
f[i * 2 + 1] = from[i][1] * dpr;
t[i * 2] = to[i][0] * dpr;
t[i * 2 + 1] = to[i][1] * dpr;
idx[i] = i / (CONVERGE_POINTS - 1);
}
const bind = (buf, data, loc, size) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
};
gl.useProgram(this.glProg);
bind(this.glBufFrom, f, this.glLoc.aFrom, 2);
bind(this.glBufTo, t, this.glLoc.aTo, 2);
bind(this.glBufIndex, idx, this.glLoc.aIndex, 1);
gl.viewport(0, 0, this.glow.width, this.glow.height);
gl.uniform2f(this.glLoc.uResolution, this.glow.width, this.glow.height);
gl.uniform1f(this.glLoc.uDpr, dpr);
const light = this._themeQuery.matches;
const color = light ? [0x47 / 255, 0x80 / 255, 0x7e / 255] : [0x8e / 255, 0xc2 / 255, 0xc0 / 255];
gl.uniform3f(this.glLoc.uColor, color[0], color[1], color[2]);
// Additive light on the dark ground; normal ink on the light one.
gl.enable(gl.BLEND);
if (light) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
else gl.blendFunc(gl.ONE, gl.ONE);
this.converging = true;
this.render();
const started = performance.now();
const step = (now) => {
const raw = (now - started) / CONVERGE_MS;
if (raw >= 1) {
// Deposit the ghost, then let the glow breathe out.
if (this.converging) {
this.converging = false;
this.render();
}
const fade = (now - started - CONVERGE_MS) / CONVERGE_FADE_MS;
if (fade >= 1) {
this.cancelConvergence();
return;
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(this.glLoc.uT, 1);
gl.uniform1f(this.glLoc.uAlpha, 0.32 * (1 - fade));
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
} else {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Trailing passes give the wave a comet tail.
for (const [lag, alpha] of [[0, 0.32], [0.035, 0.16], [0.07, 0.08], [0.105, 0.04]]) {
gl.uniform1f(this.glLoc.uT, Math.max(0, raw - lag));
gl.uniform1f(this.glLoc.uAlpha, alpha);
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
}
}
this.animFrame = requestAnimationFrame(step);
};
this.animFrame = requestAnimationFrame(step);
}
cancelConvergence() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
this.animFrame = null;
if (this.converging) {
this.converging = false;
this.render();
}
if (this.gl) {
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}
}
// ── Rendering ────────────────────────────────────────────────── // ── Rendering ──────────────────────────────────────────────────
// Draw a normalized (-1..1) path fitted into w×h with padding. // Draw a normalized (-1..1) path fitted into w×h with padding.
@@ -341,7 +567,7 @@ class GestureWidget {
if (!this.ctx) return; if (!this.ctx) return;
const t = this.theme(); const t = this.theme();
this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
if (this.ghost) { if (this.ghost && !this.converging) {
this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2); this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2);
} }
if (this.points.length >= 1) { if (this.points.length >= 1) {
@@ -360,6 +586,7 @@ class GestureWidget {
stop() { stop() {
this.stopped = true; this.stopped = true;
if (this.animFrame) cancelAnimationFrame(this.animFrame);
clearTimeout(this.emptyTimer); clearTimeout(this.emptyTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) { if (this.ws) {
@@ -378,5 +605,8 @@ class GestureWidget {
} }
export function mountGesture(container, hidden, relayUrl) { export function mountGesture(container, hidden, relayUrl) {
return new GestureWidget(container, hidden, relayUrl); // Dev convenience: `?relay=ws://127.0.0.1:9040` points every widget
// on the page at a local relay (prod relays reject foreign Origins).
const override = new URLSearchParams(window.location.search).get('relay');
return new GestureWidget(container, hidden, override || relayUrl);
} }
+2 -1
View File
@@ -1585,7 +1585,8 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
</div> </div>
} }
})} })}
{description.map(|d| view! { <p class="item-card-description">{d}</p> })} {description
.map(|d| view! { <p class="item-card-description" inner_html=render_inline_markdown(&d)></p> })}
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })} {audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
{(!extra.is_empty()) {(!extra.is_empty())
.then(|| { .then(|| {
+21
View File
@@ -199,6 +199,18 @@ pub fn render_inline_markdown(text: &str) -> String {
in_link -= 1; in_link -= 1;
None None
} }
// Images take the same gate as links: https or same-origin
// only - no data:, no plain http.
Event::Start(Tag::Image { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) =>
{
in_link += 1;
None
}
Event::End(TagEnd::Image) if in_link > 0 => {
in_link -= 1;
None
}
other => Some(other), other => Some(other),
}); });
let mut out = String::new(); let mut out = String::new();
@@ -2026,6 +2038,15 @@ alternatives:
fn markdown_drops_html_and_unsafe_links() { fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z"); assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad"); assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad");
assert_eq!(
render_inline_markdown("![site](https://x.no/a.jpg)"),
"<img src=\"https://x.no/a.jpg\" alt=\"site\" />"
);
assert_eq!(render_inline_markdown("![x](data:image/png;base64,AA)"), "");
assert_eq!(
render_inline_markdown("![local](/images/a.jpg)"),
"<img src=\"/images/a.jpg\" alt=\"local\" />"
);
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>"); assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
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>");
} }
+26 -4
View File
@@ -33,6 +33,13 @@ type OidcClient = CoreClient<
>; >;
pub struct Oidc { pub struct Oidc {
/// `None` when `KANIDM_URL` is unset: a content-only instance with
/// sign-in disabled - auth routes answer 503, everything public
/// renders as usual.
inner: Option<OidcInner>,
}
struct OidcInner {
client: OidcClient, client: OidcClient,
http: openidconnect::reqwest::Client, http: openidconnect::reqwest::Client,
} }
@@ -45,7 +52,13 @@ const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc { impl Oidc {
/// Discovers the provider and builds the client from environment: /// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`. /// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
/// the shape of a public content instance without a review desk.
pub async fn from_env() -> anyhow::Result<Self> { pub async fn from_env() -> anyhow::Result<Self> {
if std::env::var("KANIDM_URL").is_err() {
tracing::warn!("KANIDM_URL not set - sign-in disabled on this instance");
return Ok(Self { inner: None });
}
let kanidm_url = require_env("KANIDM_URL")?; let kanidm_url = require_env("KANIDM_URL")?;
let client_id = require_env("OAUTH2_CLIENT_ID")?; let client_id = require_env("OAUTH2_CLIENT_ID")?;
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?; let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
@@ -75,7 +88,16 @@ impl Oidc {
) )
.set_redirect_uri(redirect); .set_redirect_uri(redirect);
Ok(Self { client, http }) Ok(Self {
inner: Some(OidcInner { client, http }),
})
}
fn configured(&self) -> Result<&OidcInner, HandlerError> {
self.inner.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"sign-in is not configured on this instance".to_string(),
))
} }
} }
@@ -118,10 +140,10 @@ pub async fn login(
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?; session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
} }
let oidc = state.oidc.configured()?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state let (auth_url, csrf_state, nonce) = oidc
.oidc
.client .client
.authorize_url( .authorize_url(
CoreAuthenticationFlow::AuthorizationCode, CoreAuthenticationFlow::AuthorizationCode,
@@ -182,7 +204,7 @@ pub async fn callback(
)); ));
} }
let oidc = &state.oidc; let oidc = state.oidc.configured()?;
let token_response = oidc let token_response = oidc
.client .client
.exchange_code(AuthorizationCode::new(params.code)) .exchange_code(AuthorizationCode::new(params.code))
+29 -3
View File
@@ -536,6 +536,18 @@ main.not-found {
color: var(--accent); color: var(--accent);
} }
/* images inside markdown descriptions: full-width figures in the
card's flow, framed like the rest of the press sheet */
.alt-description img,
.item-card-description img,
.feature p img {
display: block;
width: 100%;
margin: 0.6rem 0 0.2rem;
border-radius: 0.5rem;
border: 0.06rem solid var(--line);
}
.alt-description code, .alt-description code,
.feature p code { .feature p code {
font-size: 0.9em; font-size: 0.9em;
@@ -647,11 +659,17 @@ textarea:focus {
touch-action: none; touch-action: none;
} }
/* Results overlay the canvas instead of growing the widget - the
page never jumps when places arrive (or don't). */
.gesture-echoes { .gesture-echoes {
position: absolute;
left: 0.6rem;
right: 0.6rem;
bottom: 0.6rem;
display: flex; display: flex;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; pointer-events: none;
min-height: 3rem;
} }
.gesture-echoes:empty { .gesture-echoes:empty {
@@ -666,6 +684,8 @@ textarea:focus {
border-radius: 0.4rem; border-radius: 0.4rem;
opacity: 0; opacity: 0;
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
pointer-events: auto;
box-shadow: 0 0.15rem 0.6rem rgba(0, 0, 0, 0.18);
} }
.gesture-echo.shown { .gesture-echo.shown {
@@ -691,9 +711,15 @@ textarea:focus {
} }
.gesture-empty { .gesture-empty {
margin: 0.5rem 0 0; position: absolute;
left: 0;
right: 0;
bottom: 0.7rem;
margin: 0;
text-align: center;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--ink-dim); color: var(--ink-dim);
pointer-events: none;
} }
.gesture-empty:empty { .gesture-empty:empty {