From 60050d80ae6685f52d40d5f232059f8b002c64c3 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 11:43:10 +0200 Subject: [PATCH 1/7] Pin portal v0.3.5 - gesture ghost for v2 keys --- .gitea/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2d11620..3768457 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -18,7 +18,7 @@ on: - .gitea/workflows/deploy.yml env: - PORTAL_RELEASE: v0.3.4 + PORTAL_RELEASE: v0.3.5 INSTANCE: redoal-portal jobs: From 039d2bf54d1fa534631367f14d10e833e5369c63 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 12:09:14 +0200 Subject: [PATCH 2/7] Deploy enables the instance unit A host reboot on 2026-08-30 left the instance down: deploys only ever started it. enable is best-effort until the host's polkit rule grants manage-unit-files to deploy-runner. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/deploy.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3768457..cd7fc79 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -83,9 +83,16 @@ jobs: # No sudo: the runner's unit sets NoNewPrivileges=yes - systemctl # talks to PID1 over D-Bus, authorized by the polkit rule scoped # to deploy-runner + the app@* unit pattern. + # enable: the unit must come back after a host reboot (2026-08-30 a + # reboot left both portal instances down - deploys had only ever + # started them). Needs the manage-unit-files polkit grant; until + # that's on the host the enable is reported and skipped, never a + # failed deploy. - name: Activate and restart run: | ln -sfn "/srv/app/$INSTANCE/releases/$PORTAL_RELEASE" /srv/app/$INSTANCE/current + systemctl enable app@$INSTANCE.service \ + || echo "::warning::could not enable app@$INSTANCE (polkit) - unit will not survive a reboot" systemctl restart app@$INSTANCE.service # www redirects to the apex for the same single-cookie-scope From f4e9db8475294755b52fda98c3edf7b9ee007113 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 12:36:48 +0200 Subject: [PATCH 3/7] Pin portal v0.3.6 - announced pages --- .gitea/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index cd7fc79..609bac6 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -18,7 +18,7 @@ on: - .gitea/workflows/deploy.yml env: - PORTAL_RELEASE: v0.3.5 + PORTAL_RELEASE: v0.3.6 INSTANCE: redoal-portal jobs: From 79ab3b37b0547b8caaeb2d80fee3918375ecbf8c Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 12:37:18 +0200 Subject: [PATCH 4/7] Review desk: announced pages; trial event page Co-Authored-By: Claude Fable 5 --- questions/review/index.yaml | 23 +++++++++++++++++++++++ questions/trial-session.yaml | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 questions/trial-session.yaml diff --git a/questions/review/index.yaml b/questions/review/index.yaml index 3a34b0d..be646ae 100644 --- a/questions/review/index.yaml +++ b/questions/review/index.yaml @@ -37,3 +37,26 @@ alternatives: transitions: - to: unsubscribed label: Unsubscribe + + - name: Announced pages + description: > + Pages with an `event:` window, kept here by portal itself. When a + window closes the record becomes a task: post what happened, + then mark it done. + action: /review + consequence: [Confirm] + features: + - name: "" + description: "" + resource: + source: + kind: kv + bucket: portal_events + requires_group: owners + transitions: + - from: awaiting_summary + to: summarized + label: Posted what happened + - from: announced + to: summarized + label: Needs no summary diff --git a/questions/trial-session.yaml b/questions/trial-session.yaml new file mode 100644 index 0000000..ce8cc3b --- /dev/null +++ b/questions/trial-session.yaml @@ -0,0 +1,20 @@ +# Trial run of portal's announced pages (event: window). Short-lived +# on purpose: it announces itself in the header for a few minutes, +# then turns into a followup and lands a summary task on /review. +name: Will you draw with us? +description: > + A five-minute drawing session — everyone at the canvas at once, to + see whose paths land near each other. +event: + starts: "2026-08-30T12:40:18+02:00" + duration: 5m + place: redoal.com +alternatives: + - name: Be at the canvas when it starts + description: > + Nothing to sign up for — draw on the landing page while the + window is open and the echoes do the rest. + features: + - name: What to bring + description: A finger or a pen, and one shape you'd recognize again. + icon: lucide:pen-line From 9a56a0fe8b5d0b3941e579d190630fc2662fe782 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 14:27:59 +0200 Subject: [PATCH 5/7] Trial event: second window for the header capture --- questions/trial-session.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/questions/trial-session.yaml b/questions/trial-session.yaml index ce8cc3b..a83a233 100644 --- a/questions/trial-session.yaml +++ b/questions/trial-session.yaml @@ -6,8 +6,8 @@ description: > A five-minute drawing session — everyone at the canvas at once, to see whose paths land near each other. event: - starts: "2026-08-30T12:40:18+02:00" - duration: 5m + starts: "2026-08-30T14:29:59+02:00" + duration: 4m place: redoal.com alternatives: - name: Be at the canvas when it starts From ecd77fae35257bd9f5e8b21ab681352d3b56798c Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 14:28:46 +0200 Subject: [PATCH 6/7] Trial event page retired - announced pages verified live Co-Authored-By: Claude Fable 5 --- questions/trial-session.yaml | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 questions/trial-session.yaml diff --git a/questions/trial-session.yaml b/questions/trial-session.yaml deleted file mode 100644 index a83a233..0000000 --- a/questions/trial-session.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Trial run of portal's announced pages (event: window). Short-lived -# on purpose: it announces itself in the header for a few minutes, -# then turns into a followup and lands a summary task on /review. -name: Will you draw with us? -description: > - A five-minute drawing session — everyone at the canvas at once, to - see whose paths land near each other. -event: - starts: "2026-08-30T14:29:59+02:00" - duration: 4m - place: redoal.com -alternatives: - - name: Be at the canvas when it starts - description: > - Nothing to sign up for — draw on the landing page while the - window is open and the echoes do the rest. - features: - - name: What to bring - description: A finger or a pen, and one shape you'd recognize again. - icon: lucide:pen-line From 588e35fd44478f1ed30d579b3d0c96802520ed4c Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 14:29:39 +0200 Subject: [PATCH 7/7] Hero: every path moves on its own, and the tip glides The trace advanced a whole sample at a time (floor of points x progress) - visible steps; the tip now interpolates between samples. The single field-wide rotate is gone: each path turns at its own seeded rate and direction about its own center, drifts on its own small orbit, carries its own wave and traces itself at its own tempo. Every rate is an integer number of cycles, so the loop stays seamless. Same generator feeds the paint worklet and the canvas. Co-Authored-By: Claude Fable 5 --- paths.js | 88 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/paths.js b/paths.js index c783855..774409d 100644 --- a/paths.js +++ b/paths.js @@ -72,48 +72,96 @@ export function turningFunctionToPoints(turning, samples = 14) { return pts; } +// A path plus the motion that is its own: where it sits, how it +// spins, how it drifts, how fast it traces itself in. Every rate is +// an integer number of cycles so the field loops seamlessly at t = 1. export function makePaths(seed, count, stops = 10) { const rand = mulberry32(seed); const paths = []; for (let i = 0; i < count; i++) { - paths.push(turningFunctionToPoints(createTurningFunction(rand, stops))); + const pts = turningFunctionToPoints(createTurningFunction(rand, stops)); + let cx = 0, cy = 0; + for (let k = 0; k < pts.length; k += 2) { cx += pts[k]; cy += pts[k + 1]; } + cx /= pts.length / 2; + cy /= pts.length / 2; + const sign = rand() > 0.5 ? 1 : -1; + paths.push({ + pts, + cx, + cy, + // own turns per cycle: a third sit still, the rest turn one + // or two full turns either way over the cycle + spin: rand() < 0.33 ? 0 : sign * (1 + Math.floor(rand() * 2)), + // slow orbit around its own place, radius in frame units + driftR: 0.01 + rand() * 0.03, + driftK: 1 + Math.floor(rand() * 3), + driftPhase: rand() * Math.PI * 2, + // its own wave along the line + waveF: 4 + rand() * 6, + waveK: 1 + Math.floor(rand() * 2), + waveA: 0.004 + rand() * 0.008, + // tracing tempo: how many times it redraws itself per cycle + tempo: 1 + Math.floor(rand() * 2), + phase: rand(), + }); } return paths; } -// t in [0, 1) is the animation phase. Each path traces itself in over -// one cycle, offset by its index so the field is always mid-draw, and -// fades as it completes; the whole field drifts slowly and a soft -// wave runs through every line. `colors` is [accent, dim] - the -// palette comes from the page's own custom properties either way. +// t in [0, 1) is the animation phase. Each path traces itself in at +// its own tempo and phase, fades as it completes, turns at its own +// rate about its own center and drifts on its own small orbit; a wave +// with its own frequency runs along it. The tip is interpolated +// between samples so a trace glides instead of stepping. `colors` is +// [accent, dim] - the palette comes from the page's custom properties. +const smooth = (u) => u * u * (3 - 2 * u); + export function drawPaths(ctx, width, height, paths, t, colors) { ctx.clearRect(0, 0, width, height); const s = Math.min(width, height) * 0.9; ctx.save(); ctx.translate(width / 2, height / 2); - ctx.rotate(t * Math.PI * 2 * 0.08); ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - const n = paths.length; - const wave = t * Math.PI * 2; - for (let i = 0; i < n; i++) { - const pts = paths[i]; - const phase = (t + i / n) % 1; - // ease: quick to appear, long to complete - const progress = Math.min(1, phase * 1.35); - const count = Math.max(2, Math.floor((pts.length / 2) * progress)); + const TAU = Math.PI * 2; + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + const phase = (t * p.tempo + p.phase) % 1; + // ease: quick to appear, long to complete, then fade + const progress = smooth(Math.min(1, phase * 1.35)); const alpha = phase < 0.85 ? 0.75 : 0.75 * (1 - (phase - 0.85) / 0.15); + const total = p.pts.length / 2; + const tip = 1 + (total - 1) * progress; // fractional sample index + const whole = Math.floor(tip); + const frac = tip - whole; + + const angle = p.spin * TAU * t; + const dx = Math.cos(p.driftPhase + p.driftK * TAU * t) * p.driftR; + const dy = Math.sin(p.driftPhase + p.driftK * TAU * t) * p.driftR; + const wave = p.waveK * TAU * t; + + ctx.save(); + ctx.translate((p.cx + dx) * s, (p.cy + dy) * s); + ctx.rotate(angle); ctx.strokeStyle = i % 3 === 0 ? colors[0] : colors[1]; ctx.globalAlpha = alpha * (i % 3 === 0 ? 1 : 0.55); ctx.beginPath(); - for (let k = 0; k < count; k++) { - const x = pts[k * 2], y = pts[k * 2 + 1]; - const wy = y + Math.sin(x * 7 + wave + i) * 0.008; - if (k === 0) ctx.moveTo(x * s, wy * s); - else ctx.lineTo(x * s, wy * s); + const at = (k) => { + const x = p.pts[k * 2] - p.cx, y = p.pts[k * 2 + 1] - p.cy; + return [x, y + Math.sin(x * p.waveF + wave) * p.waveA]; + }; + for (let k = 0; k <= Math.min(whole, total - 1); k++) { + const [x, y] = at(k); + if (k === 0) ctx.moveTo(x * s, y * s); + else ctx.lineTo(x * s, y * s); + } + if (whole < total - 1 && frac > 0) { + const [ax, ay] = at(whole), [bx, by] = at(whole + 1); + ctx.lineTo((ax + (bx - ax) * frac) * s, (ay + (by - ay) * frac) * s); } ctx.stroke(); + ctx.restore(); } ctx.restore(); }