Compare commits
52
Commits
c5b445ecb1
..
main
@@ -18,7 +18,7 @@ on:
|
||||
- .gitea/workflows/deploy.yml
|
||||
|
||||
env:
|
||||
PORTAL_RELEASE: v0.3.0
|
||||
PORTAL_RELEASE: v0.3.32
|
||||
INSTANCE: redoal-portal
|
||||
|
||||
jobs:
|
||||
@@ -67,6 +67,10 @@ jobs:
|
||||
# The release tarball carries the site bundle at site/ (no
|
||||
# target/ prefix), so override Cargo.toml's build-time path.
|
||||
LEPTOS_SITE_ROOT=site
|
||||
# Portal ships content-hashed pkg files (portal.<hash>.js) with
|
||||
# a hash.txt in the site root; this makes the server reference
|
||||
# them, so a stale cached bundle can never pair with new wasm.
|
||||
LEPTOS_HASH_FILES=true
|
||||
# Read-only Gitea token - here it also backs the
|
||||
# gitea_releases source for the private redoal/redoal repo,
|
||||
# so the owning user needs read access there.
|
||||
@@ -79,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
|
||||
|
||||
@@ -11,11 +11,15 @@ The full schema reference lives in
|
||||
this repo only notes what's distinctive here:
|
||||
|
||||
- **`site.yaml`** rebrands the instance: title `redoal`, its own
|
||||
wordmark, and a `module` hero — `hero.js` in this repo, the drifting
|
||||
"sine swings" band grown from lysbue's logo (pure SVG + CSS, colors
|
||||
from portal's own custom properties, still under
|
||||
prefers-reduced-motion). No input up there: the form's gesture
|
||||
canvas below is the one place a visitor draws. The wordmark is
|
||||
wordmark, and a `module` hero — `hero.js` in this repo, lysbue's
|
||||
"paths" paint worklet brought back and animated: 47 generative
|
||||
turning-function curves (`paths.js`, seeded so every repaint agrees)
|
||||
tracing themselves in from the center. A real CSS Paint Worklet
|
||||
where the API exists (`paths-worklet.js`, driven by a registered
|
||||
`--redoal-t` CSS animation), a canvas with the same generator
|
||||
elsewhere; colors from portal's custom properties, frozen under
|
||||
prefers-reduced-motion. No input up there: the form's gesture canvas
|
||||
below is the one place a visitor draws. The wordmark is
|
||||
white-stroked like uhhm's, because portal's light theme lands
|
||||
wordmarks as ink via an invert filter.
|
||||
- **`type: gesture`** on `index.yaml`'s "Draw a path and see who's near" — the
|
||||
|
||||
@@ -14,3 +14,27 @@ aggregates:
|
||||
unsubscribed: { event: unsubscribed }
|
||||
transitions:
|
||||
open: [unsubscribed]
|
||||
# Double opt-in: a watcher is pending until the address confirms from
|
||||
# the mail n8n sends (redoal: confirm watching); only open watchers
|
||||
# are told when something lands at their shape.
|
||||
- bucket: redoal_watchers
|
||||
initial: pending
|
||||
states:
|
||||
pending: { event: requested }
|
||||
open: { event: confirmed }
|
||||
unsubscribed: { event: unsubscribed }
|
||||
transitions:
|
||||
pending: [open, unsubscribed]
|
||||
open: [unsubscribed]
|
||||
# Reports on recordings. The relay watches this bucket: a record
|
||||
# moved to "removed" deletes the recording from its store, the varde
|
||||
# pin, and discovery. "kept" closes the report with the recording in
|
||||
# place. Decisions are human, never automatic.
|
||||
- bucket: redoal_reports
|
||||
initial: open
|
||||
states:
|
||||
open: { event: reported }
|
||||
removed: { event: removed }
|
||||
kept: { event: kept }
|
||||
transitions:
|
||||
open: [removed, kept]
|
||||
|
||||
@@ -1,66 +1,65 @@
|
||||
// redoal.com's hero: "sine swings" - the drifting layered sine paths
|
||||
// from lysbue's logo (the site this content descends from), grown from
|
||||
// a clipped 400px circle into a full-width band. No input: the form's
|
||||
// gesture canvas below is the one place a visitor draws. Content-owned:
|
||||
// portal knows only site.yaml's `hero: {kind: module, module: hero.js}`
|
||||
// and the contract - `mount(container) -> handle` with `stop()`; it
|
||||
// serves this file same-origin at /site/hero.js, starts it at HTML
|
||||
// parse time, adopts it on hydration, and stops it on navigation.
|
||||
// redoal.com's hero: lysbue's "paths" paint worklet, animated - the
|
||||
// site this content descends from painted its pages with generative
|
||||
// turning-function curves (static/worklet/paint/paths.js); this is
|
||||
// that field, alive. No input up here: the form's gesture canvas is
|
||||
// the one place a visitor draws.
|
||||
//
|
||||
// Pure SVG + CSS: the strokes take their color from portal's own
|
||||
// custom properties (--accent, --ink-dim), so both themes come for
|
||||
// free and there's no palette copy to keep in sync. Motion is a CSS
|
||||
// keyframe on each layer's transform, disabled under
|
||||
// prefers-reduced-motion.
|
||||
// Content-owned: portal knows only site.yaml's `hero: {kind: module,
|
||||
// module: hero.js}` and the contract - `mount(container) -> handle`
|
||||
// with `stop()` - and serves this repo's files same-origin under
|
||||
// /site/. Two renderers, one generator (paths.js):
|
||||
//
|
||||
// - Where the CSS Paint API exists (Chromium), the real thing: the
|
||||
// worklet paints the box's background and a CSS animation on the
|
||||
// registered --redoal-t property drives repaints - no JS per frame.
|
||||
// - Elsewhere (Safari, Firefox, WebKitGTK have no Paint API), a
|
||||
// canvas with a requestAnimationFrame loop draws the identical
|
||||
// field, colors read from the page's custom properties.
|
||||
//
|
||||
// prefers-reduced-motion freezes the field fully drawn either way.
|
||||
|
||||
import { makePaths, drawPaths, OVERSCAN } from './paths.js';
|
||||
|
||||
const STYLE_ID = 'redoal-hero-style';
|
||||
const CYCLE_MS = 28000;
|
||||
const SEED = 7;
|
||||
const COUNT = 47;
|
||||
|
||||
// One period is 400 units wide; the path is written two periods long
|
||||
// so translating by exactly one period loops seamlessly.
|
||||
const SWING =
|
||||
'M 0 200 Q 100 100 200 200 Q 300 300 400 200 ' +
|
||||
'Q 500 100 600 200 Q 700 300 800 200 ' +
|
||||
'Q 900 100 1000 200 Q 1100 300 1200 200';
|
||||
const BLEED = `${((OVERSCAN - 1) / 2) * -100}%`;
|
||||
|
||||
// (y offset, stroke width, opacity, seconds per period, direction)
|
||||
const LAYERS = [
|
||||
[0, 6.5, 0.55, 9, 1],
|
||||
[7, 3, 0.45, 11, 1],
|
||||
[9, 1, 0.5, 13, 1],
|
||||
[15, 0.5, 0.6, 17, 1],
|
||||
[-60, 1.5, 0.18, 23, -1],
|
||||
[70, 1, 0.14, 29, -1],
|
||||
];
|
||||
|
||||
const CSS = `
|
||||
.redoal-hero {
|
||||
const STYLE_TEXT = `
|
||||
/* The field bleeds 25% past the hero piece on every side. Sideways
|
||||
it is clipped at the header box (clip, not hidden: no scroll
|
||||
container, and overflow-y stays visible) - an element wider than
|
||||
the viewport, even unscrollable, makes phones re-size the layout
|
||||
viewport when it appears, which read as the page jumping as the
|
||||
input loaded. Vertically it may run into the copy below. */
|
||||
.hero-module { overflow-x: clip; overflow-y: visible; }
|
||||
.redoal-paths {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
inset: ${BLEED};
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
--redoal-t: 0;
|
||||
--redoal-seed: ${SEED};
|
||||
--redoal-count: ${COUNT};
|
||||
}
|
||||
.redoal-hero svg {
|
||||
.redoal-paths.worklet {
|
||||
background: paint(redoal-paths);
|
||||
animation: redoal-t ${CYCLE_MS}ms linear infinite;
|
||||
}
|
||||
.redoal-paths canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.redoal-hero .swing {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-linecap: round;
|
||||
animation: redoal-swing var(--dur) linear infinite;
|
||||
animation-direction: var(--dir);
|
||||
}
|
||||
.redoal-hero .swing.far {
|
||||
stroke: var(--ink-dim);
|
||||
}
|
||||
@keyframes redoal-swing {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-400px); }
|
||||
@keyframes redoal-t {
|
||||
from { --redoal-t: 0; }
|
||||
to { --redoal-t: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.redoal-hero .swing { animation: none; }
|
||||
.redoal-paths.worklet { animation: none; --redoal-t: 0.999; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -68,67 +67,105 @@ function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
style.textContent = STYLE_TEXT;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
class SineSwings {
|
||||
function hasPaintApi() {
|
||||
return typeof CSS !== 'undefined' && 'paintWorklet' in CSS && typeof CSS.registerProperty === 'function';
|
||||
}
|
||||
|
||||
class PathsHero {
|
||||
constructor(container) {
|
||||
ensureStyle();
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'redoal-hero';
|
||||
this.root.className = 'redoal-paths';
|
||||
this.root.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// viewBox 400 wide per period; preserveAspectRatio "slice" so
|
||||
// the band always fills the box and the loop is seamless at
|
||||
// any width - the SVG is 1200 wide but only translates by 400.
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 800 400');
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
|
||||
|
||||
// The logo's clipped circle, centered - the near layers live
|
||||
// inside it; the far layers drift across the whole band.
|
||||
const defs = document.createElementNS(NS, 'defs');
|
||||
const clip = document.createElementNS(NS, 'clipPath');
|
||||
clip.id = 'redoal-hero-clip';
|
||||
const circle = document.createElementNS(NS, 'circle');
|
||||
circle.setAttribute('cx', '400');
|
||||
circle.setAttribute('cy', '200');
|
||||
circle.setAttribute('r', '150');
|
||||
clip.appendChild(circle);
|
||||
defs.appendChild(clip);
|
||||
svg.appendChild(defs);
|
||||
|
||||
const far = document.createElementNS(NS, 'g');
|
||||
const near = document.createElementNS(NS, 'g');
|
||||
near.setAttribute('clip-path', 'url(#redoal-hero-clip)');
|
||||
// The logo's tilt.
|
||||
near.setAttribute('transform', 'rotate(-13 400 200)');
|
||||
|
||||
for (const [dy, width, opacity, dur, dir] of LAYERS) {
|
||||
const path = document.createElementNS(NS, 'path');
|
||||
path.setAttribute('d', SWING);
|
||||
path.setAttribute('stroke-width', String(width));
|
||||
path.setAttribute('opacity', String(opacity));
|
||||
path.setAttribute('transform', `translate(-200 ${dy})`);
|
||||
const isFar = Math.abs(dy) > 30;
|
||||
path.setAttribute('class', isFar ? 'swing far' : 'swing');
|
||||
path.style.setProperty('--dur', `${dur}s`);
|
||||
path.style.setProperty('--dir', dir < 0 ? 'reverse' : 'normal');
|
||||
(isFar ? far : near).appendChild(path);
|
||||
}
|
||||
svg.append(far, near);
|
||||
this.root.appendChild(svg);
|
||||
container.appendChild(this.root);
|
||||
this.stopped = false;
|
||||
|
||||
if (hasPaintApi()) {
|
||||
this.startWorklet();
|
||||
} else {
|
||||
this.startCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
startWorklet() {
|
||||
// Registering makes --redoal-t a real <number> the animation
|
||||
// can interpolate; a second mount in the same document sees it
|
||||
// already registered, which throws and is fine.
|
||||
try {
|
||||
CSS.registerProperty({
|
||||
name: '--redoal-t',
|
||||
syntax: '<number>',
|
||||
inherits: false,
|
||||
initialValue: '0',
|
||||
});
|
||||
} catch {
|
||||
/* already registered */
|
||||
}
|
||||
const url = new URL('./paths-worklet.js', import.meta.url).href;
|
||||
CSS.paintWorklet
|
||||
.addModule(url)
|
||||
.then(() => {
|
||||
if (!this.stopped) this.root.classList.add('worklet');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!this.stopped) this.startCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
startCanvas() {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.root.appendChild(this.canvas);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.paths = makePaths(SEED, COUNT);
|
||||
this._onResize = () => this.size();
|
||||
window.addEventListener('resize', this._onResize);
|
||||
this.size();
|
||||
if (reducedMotion()) {
|
||||
this.frame(0.999);
|
||||
return;
|
||||
}
|
||||
this.start = performance.now();
|
||||
const loop = (now) => {
|
||||
if (this.stopped) return;
|
||||
this.frame(((now - this.start) % CYCLE_MS) / CYCLE_MS);
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
};
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
size() {
|
||||
const rect = this.root.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.w = Math.max(1, rect.width);
|
||||
this.h = Math.max(1, rect.height);
|
||||
this.canvas.width = Math.round(this.w * dpr);
|
||||
this.canvas.height = Math.round(this.h * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
this.frame(this.lastT || 0);
|
||||
}
|
||||
|
||||
frame(t) {
|
||||
this.lastT = t;
|
||||
const cs = getComputedStyle(this.root);
|
||||
const accent = cs.getPropertyValue('--accent').trim() || '#47807e';
|
||||
const dim = cs.getPropertyValue('--ink-dim').trim() || '#6d6c66';
|
||||
drawPaths(this.ctx, this.w, this.h, this.paths, t, [accent, dim]);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.raf) cancelAnimationFrame(this.raf);
|
||||
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function mount(container) {
|
||||
return new SineSwings(container);
|
||||
return new PathsHero(container);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// CSS Paint Worklet entry for redoal.com's hero: `background:
|
||||
// paint(redoal-paths)` on the hero box, animated by the page changing
|
||||
// --redoal-t (a registered <number> custom property driven by a CSS
|
||||
// animation - every change repaints). Colors arrive as computed input
|
||||
// properties, so the worklet follows the theme without a palette copy.
|
||||
import { makePaths, drawPaths } from './paths.js';
|
||||
|
||||
registerPaint(
|
||||
'redoal-paths',
|
||||
class {
|
||||
static get inputProperties() {
|
||||
return ['--redoal-t', '--redoal-seed', '--redoal-count', '--accent', '--ink-dim'];
|
||||
}
|
||||
paint(ctx, size, props) {
|
||||
const num = (name, fallback) => {
|
||||
const v = parseFloat(props.get(name).toString());
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
};
|
||||
const seed = num('--redoal-seed', 7);
|
||||
const count = num('--redoal-count', 47);
|
||||
const t = num('--redoal-t', 0);
|
||||
if (!this.paths || this.seed !== seed || this.count !== count) {
|
||||
this.paths = makePaths(seed, count);
|
||||
this.seed = seed;
|
||||
this.count = count;
|
||||
}
|
||||
const accent = props.get('--accent').toString().trim() || '#47807e';
|
||||
const dim = props.get('--ink-dim').toString().trim() || '#6d6c66';
|
||||
drawPaths(ctx, size.width, size.height, this.paths, t % 1, [accent, dim]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
// The generator behind redoal.com's hero - a port of lysbue's
|
||||
// "paths" CSS paint worklet (static/worklet/paint/paths.js): a path is
|
||||
// a turning function, a list of (angle shift, distance) stops walked
|
||||
// from the center and smoothed into quadratic arcs. Shared verbatim by
|
||||
// the paint worklet (paths-worklet.js, where the browser has the CSS
|
||||
// Paint API) and the canvas fallback (hero.js) so both draw the same
|
||||
// field. Everything is seeded: a worklet is instantiated whenever the
|
||||
// engine likes, and unseeded randomness would reshuffle the picture on
|
||||
// every repaint.
|
||||
|
||||
// The field bleeds past its box: the painted element is this much
|
||||
// larger than the hero piece on each axis (25% each side), and the
|
||||
// drawing scales to the piece, not the element, so nothing grows.
|
||||
export const OVERSCAN = 1.5;
|
||||
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function isInBounds(x, y) {
|
||||
return x > -0.5 && x < 0.5 && y > -0.5 && y < 0.5;
|
||||
}
|
||||
|
||||
// Flat array of (angle shift, distance) pairs; distance 1 = the
|
||||
// frame's short side. The first pair is the start: absolute angle and
|
||||
// distance from the center.
|
||||
export function createTurningFunction(rand, stopCount) {
|
||||
const turning = [rand() * Math.PI * 2, rand() * 0.3];
|
||||
let radianSum = turning[0];
|
||||
let x = Math.cos(turning[0]) * turning[1];
|
||||
let y = Math.sin(turning[0]) * turning[1];
|
||||
for (let i = 0; i < stopCount; i++) {
|
||||
let shiftAngle = 0, shiftDistance = 0.1, next = radianSum;
|
||||
for (let j = 0; j < 80; j++) {
|
||||
shiftAngle = Math.pow(1 + rand() * 4, -2) * Math.PI * 2 * (rand() > 0.5 ? -1 : 1);
|
||||
shiftDistance = Math.max(rand() * 0.25, 0.05);
|
||||
next = radianSum + shiftAngle;
|
||||
if (isInBounds(x + Math.cos(next) * shiftDistance, y + Math.sin(next) * shiftDistance)) break;
|
||||
}
|
||||
radianSum = next;
|
||||
x += Math.cos(radianSum) * shiftDistance;
|
||||
y += Math.sin(radianSum) * shiftDistance;
|
||||
turning.push(shiftAngle, shiftDistance);
|
||||
}
|
||||
return turning;
|
||||
}
|
||||
|
||||
// Smooth the stops into quadratic arcs (control point = half a step
|
||||
// along the previous heading, like the original) and flatten each arc
|
||||
// into `samples` points so the curve can be traced progressively.
|
||||
export function turningFunctionToPoints(turning, samples = 14) {
|
||||
const pts = [];
|
||||
let heading = turning[0];
|
||||
let x = Math.cos(turning[0]) * turning[1];
|
||||
let y = Math.sin(turning[0]) * turning[1];
|
||||
pts.push(x, y);
|
||||
for (let i = 2; i < turning.length; i += 2) {
|
||||
const cx = x + Math.cos(heading) * (turning[i + 1] / 2);
|
||||
const cy = y + Math.sin(heading) * (turning[i + 1] / 2);
|
||||
heading += turning[i];
|
||||
const nx = x + Math.cos(heading) * turning[i + 1];
|
||||
const ny = y + Math.sin(heading) * turning[i + 1];
|
||||
for (let s = 1; s <= samples; s++) {
|
||||
const u = s / samples, v = 1 - u;
|
||||
pts.push(v * v * x + 2 * v * u * cx + u * u * nx, v * v * y + 2 * v * u * cy + u * u * ny);
|
||||
}
|
||||
x = nx;
|
||||
y = ny;
|
||||
}
|
||||
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++) {
|
||||
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 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) / OVERSCAN) * 0.9;
|
||||
ctx.save();
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
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();
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# The group exhibition at Podium, part of Attac's «Big Tech må vekk»
|
||||
# 2026: opens 23 October 2026 at 19:00 and runs for a month. Announced
|
||||
# in every header while it runs (portal's event: window); afterwards a
|
||||
# followup, and a "post what happened" task lands on /review.
|
||||
name: Where can you see it in person?
|
||||
description: >
|
||||
redoal is part of the group exhibition at Podium, opening 23 October
|
||||
and running for a month — the art strand of the conference
|
||||
«Big Tech må vekk». Draw on the screens, leave a voice at a shape,
|
||||
hear who came before you.
|
||||
event:
|
||||
starts: 2026-10-23T19:00:00+02:00
|
||||
duration: 31d
|
||||
place: Podium, Hausmanns gate 34, Oslo
|
||||
alternatives:
|
||||
- name: Come draw on the wall
|
||||
description: >
|
||||
The installation runs the same code as this site, on screens
|
||||
you can stand in front of together — the network at the scale
|
||||
of a room.
|
||||
features:
|
||||
- name: Opening 23 October, 19:00
|
||||
description: >
|
||||
At [Podium](https://podium.enterprises/), Hausmanns gate 34,
|
||||
and up for a month. Come to the opening, or any Saturday
|
||||
or Sunday after.
|
||||
icon: lucide:calendar-days
|
||||
- name: Part of «Big Tech må vekk»
|
||||
description: >
|
||||
Attac's conference on digital counterculture returns to
|
||||
Hausmania — the same building — on 31 October; the
|
||||
exhibition is its art strand. Programme and details on
|
||||
[attac.no](https://attac.no/arrangement/big-tech-ma-vekk-2026/).
|
||||
icon: lucide:megaphone
|
||||
- name: Draw, then listen
|
||||
description: >
|
||||
Draw a shape on the touch screen and arrive at a place.
|
||||
Listen to the voices left there, add your own, and watch
|
||||
your line find the lines drawn before it.
|
||||
icon: lucide:hand
|
||||
- name: Take the zine with you
|
||||
description: >
|
||||
The printed zine sits in the room — six short pieces on what
|
||||
a network like this could mean. Fold one, hand it on.
|
||||
icon: lucide:book-open
|
||||
+71
-10
@@ -1,9 +1,10 @@
|
||||
id: /
|
||||
name: What shapes us?
|
||||
description: >
|
||||
redoal is a gesture-addressed network — peers find each other by
|
||||
drawing similar shapes. The gesture is the address. Draw one above
|
||||
and see who's near.
|
||||
redoal is a gesture-addressed network. Draw a line and it becomes
|
||||
an address — the drawing itself, ready to use the moment your hand
|
||||
lifts. Similar drawings land near each other, and everything else
|
||||
follows from that. Draw one below and see who's near.
|
||||
responsible:
|
||||
name: Bendik Aagaard Lynghaug
|
||||
contact: bl@uhhm.no
|
||||
@@ -12,9 +13,10 @@ alternatives:
|
||||
description: >
|
||||
By associating similar movements while facilitating discourse we
|
||||
discover signal within chaos. Draw a path — the network answers
|
||||
with the shapes it heard nearby.
|
||||
action: /shape
|
||||
consequence: [Find what follows]
|
||||
with the shape it heard, and with the places kept nearby. Pick
|
||||
one of those and it becomes your address.
|
||||
action: /shape/{curve.key}
|
||||
consequence: [Go to the place]
|
||||
record_as: redoal_gestures
|
||||
encouragements:
|
||||
- What you don't know — your collaborators will.
|
||||
@@ -32,10 +34,68 @@ alternatives:
|
||||
type: gesture
|
||||
relay: wss://relay.redoal.com
|
||||
optional: true
|
||||
- name: email
|
||||
label: Email, if the echoes should reach you later
|
||||
type: email
|
||||
optional: true
|
||||
|
||||
- name: Meet a network you carry in your hand
|
||||
description: >
|
||||
The one trick, and what it enables — the parts that run today,
|
||||
said plainly.
|
||||
features:
|
||||
- name: A drawing is an address
|
||||
description: >
|
||||
The computer measures how your line curves, turns, and keeps
|
||||
rhythm, and boils it down to a twenty-byte fingerprint.
|
||||
Similar drawings get similar fingerprints — two spirals land
|
||||
like two houses on the same street, and that closeness is
|
||||
the whole network.
|
||||
icon: lucide:map-pin
|
||||
- name: Your machine asks the room
|
||||
description: >
|
||||
To find someone, your machine asks the machines around it:
|
||||
"has anyone been near this shape?" Your spiral finds my
|
||||
spiral directly, peer to peer. The whole directory is the
|
||||
shapes people know, passed along on a napkin.
|
||||
icon: lucide:users
|
||||
- name: Voices stay where you left them
|
||||
description: >
|
||||
A drawing is a place, and places hold voices. Listen to the
|
||||
recordings people left at a gesture, leave your own on top,
|
||||
and when someone stands at the same drawing right now, just
|
||||
talk — live, machine to machine.
|
||||
icon: lucide:mic
|
||||
- name: Sealed by your own hand
|
||||
description: >
|
||||
Every recording and every stored stroke carries a signature
|
||||
from the leaver's own machine — proof that it arrived
|
||||
intact and that the same hand made the same things. The key
|
||||
lives only with you: keep it and you're recognized, delete
|
||||
it and you were never here.
|
||||
icon: lucide:stamp
|
||||
- name: Draw and speak, and you're in
|
||||
description: >
|
||||
The primitives are the drawn line and the human voice. A
|
||||
child, an elder, or a newcomer still learning the local
|
||||
script arrives on the same terms as everyone else — that is
|
||||
the whole entry requirement.
|
||||
icon: lucide:hand
|
||||
- name: Carried by iroh, through varde
|
||||
description: >
|
||||
What is left at a place is kept and mirrored by
|
||||
[varde](https://project.uhhm.no/uhhm/varde), a small
|
||||
daemon that moves bytes between consenting machines over
|
||||
[iroh](https://iroh.computer) — verified, rate-limited, and
|
||||
legible to the network it runs on. Recordings travel that way,
|
||||
with no company carrying them.
|
||||
icon: lucide:waypoints
|
||||
- name: Built in the open at UHHM
|
||||
description: >
|
||||
[UHHM](https://uhhm.no) builds and carries the gesture math,
|
||||
the relay, and the portal this page runs on — and hosts the
|
||||
network's first node. It is a prototype and an artwork before
|
||||
it is a product: what you can touch is real and running, the
|
||||
transport that reaches across the internet is being built
|
||||
now, and the rough edges are where the interesting questions
|
||||
live.
|
||||
icon: lucide:anvil
|
||||
|
||||
- name: Follow the build as it lands
|
||||
description: >
|
||||
@@ -57,6 +117,7 @@ alternatives:
|
||||
owner: redoal
|
||||
repo: redoal
|
||||
public: true
|
||||
empty: Nothing released yet — the first cut lands here.
|
||||
# url pinned to null while the repo is private - a private
|
||||
# release's html_url 404s for anonymous visitors, and the
|
||||
# card renders an unlinked heading instead (same lesson as
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# One recording, held up for human review: /report/<digest>. Reached
|
||||
# from the place page's per-recording link. Filing creates an open
|
||||
# record in redoal_reports; the desk decides, and a removal decision
|
||||
# deletes the recording from the relay, varde, and discovery.
|
||||
name: Should this stay?
|
||||
description: >
|
||||
Every voice here was left by a person, and a person looks at every
|
||||
report. Tell us what you heard and why it needs eyes on it — the
|
||||
recording stays reachable until someone has listened and decided.
|
||||
alternatives:
|
||||
- name: Ask for a review
|
||||
description: >
|
||||
Goes straight to the review desk. If the recording breaks
|
||||
something — a person's privacy, the law, basic decency — it is
|
||||
removed everywhere this network keeps it.
|
||||
action: /reported
|
||||
consequence: [Send it to review]
|
||||
record_as: redoal_reports
|
||||
encouragements:
|
||||
- A person will listen.
|
||||
features:
|
||||
- name: What you heard
|
||||
description: >
|
||||
A sentence or two is plenty. Leave an email if you want to
|
||||
hear what was decided.
|
||||
icon: lucide:flag
|
||||
requirements:
|
||||
- name: digest
|
||||
label: Recording
|
||||
type: hidden
|
||||
value: "{digest}"
|
||||
- name: reason
|
||||
label: Why
|
||||
type: textarea
|
||||
placeholder: What is wrong with this recording?
|
||||
- name: email
|
||||
label: Email
|
||||
type: email
|
||||
optional: true
|
||||
- name: Go back to listening
|
||||
description: >
|
||||
The place you came from holds the rest of its voices.
|
||||
features:
|
||||
- name: Nothing sent
|
||||
description: >
|
||||
Leaving this page files nothing — only the button does.
|
||||
icon: lucide:undo-2
|
||||
@@ -0,0 +1,10 @@
|
||||
# Landing after filing a report.
|
||||
name: What happens now?
|
||||
description: >
|
||||
Your report is on the review desk. A person listens, decides, and
|
||||
the decision acts everywhere this network keeps the recording.
|
||||
alternatives:
|
||||
- name: Draw another
|
||||
description: >
|
||||
The paper keeps listening.
|
||||
action: /
|
||||
@@ -37,3 +37,90 @@ alternatives:
|
||||
transitions:
|
||||
- to: unsubscribed
|
||||
label: Unsubscribe
|
||||
|
||||
- name: Voices
|
||||
description: >
|
||||
Recordings left through the web, one answer per upload: the
|
||||
address, the digest, the length. The audio itself lives in the
|
||||
relay's node and in varde.
|
||||
features:
|
||||
- name: ""
|
||||
description: ""
|
||||
resource:
|
||||
source:
|
||||
kind: kv
|
||||
bucket: redoal_voices
|
||||
requires_group: owners
|
||||
|
||||
- name: Reports
|
||||
description: >
|
||||
Recordings a visitor asked a person to listen to. "Remove it"
|
||||
acts for real: the relay watches this bucket and deletes the
|
||||
recording from its store, the varde pin, and discovery. "Keep
|
||||
it" closes the report with the voice in place.
|
||||
action: /review
|
||||
consequence: [Decide]
|
||||
features:
|
||||
- name: ""
|
||||
description: ""
|
||||
resource:
|
||||
source:
|
||||
kind: kv
|
||||
bucket: redoal_reports
|
||||
requires_group: owners
|
||||
transitions:
|
||||
- from: open
|
||||
to: removed
|
||||
label: Remove it
|
||||
- from: open
|
||||
to: kept
|
||||
label: Keep it
|
||||
|
||||
- name: Watchers
|
||||
description: >
|
||||
Who asked to hear from one exact address. Unsubscribing is
|
||||
theirs (the link in their receipt); the desk can retire an
|
||||
address that bounces.
|
||||
action: /review
|
||||
consequence: [Confirm]
|
||||
features:
|
||||
- name: ""
|
||||
description: ""
|
||||
resource:
|
||||
source:
|
||||
kind: kv
|
||||
bucket: redoal_watchers
|
||||
requires_group: owners
|
||||
transitions:
|
||||
- from: pending
|
||||
to: open
|
||||
label: Confirm by hand
|
||||
- from: pending
|
||||
to: unsubscribed
|
||||
label: Drop
|
||||
- from: open
|
||||
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
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
id: /shape
|
||||
route: ^/shape
|
||||
followup: true
|
||||
name: Where does the path lead?
|
||||
description: >
|
||||
Your shape is in the room now. Echoes of it reach whoever draws
|
||||
nearby — and theirs reach you, whenever you come back to draw again.
|
||||
alternatives:
|
||||
- name: What just happened
|
||||
description: ""
|
||||
features:
|
||||
- name: An address, not an upload
|
||||
description: >
|
||||
The stroke became a 20-byte gesture key — a compressed
|
||||
encoding of its shape. Similar shapes land on nearby keys;
|
||||
that proximity is the whole network.
|
||||
- name: Nothing to undo
|
||||
description: >
|
||||
A drawn path is a fact, not an account. There is no profile
|
||||
behind it unless you left an email.
|
||||
|
||||
- name: Draw another
|
||||
description: >
|
||||
The network gets more interesting with every shape it hears.
|
||||
action: /
|
||||
consequence: [Back to the canvas]
|
||||
@@ -0,0 +1,101 @@
|
||||
# The place a drawn (or picked) path leads to: /shape/<gesture key>.
|
||||
# Reached through the landing alternative's templated action
|
||||
# (/shape/{curve.key}); the key is the URL. What is kept here comes
|
||||
# from the relay - the first redoal node - over its /place endpoint,
|
||||
# and a voice left here goes back the same way (ADR-0015 in redoal).
|
||||
name: Where does the path lead?
|
||||
description: >
|
||||
This is a place: the 20-byte address your path became — or the one
|
||||
you picked from what the network keeps nearby. Voices left here play
|
||||
here, for whoever draws their way to this shape. What you see on
|
||||
this site is a preview of the app: the same network, reached
|
||||
through a relay instead of your own machine.
|
||||
alternatives:
|
||||
- name: Listen to what was left here
|
||||
description: ""
|
||||
features:
|
||||
- name: ""
|
||||
description: ""
|
||||
icon: lucide:ear
|
||||
resource:
|
||||
source:
|
||||
kind: url
|
||||
url: "https://relay.redoal.com/place/{key}"
|
||||
public: true
|
||||
empty: Nothing has been left at this address yet — yours could be the first voice here.
|
||||
jq: >
|
||||
.recordings[] | {name: ((.duration_ms / 1000 | round | tostring) + " seconds of voice"),
|
||||
description: ("sealed by " + .author[0:8] + "… · [have it reviewed](/report/" + .digest + ")"),
|
||||
audio: ("https://relay.redoal.com" + .audio)}
|
||||
|
||||
- name: Leave your voice here
|
||||
description: >
|
||||
A preview of what the app will give you in full. Record in your
|
||||
browser, listen back, and press *Keep it here* — nothing leaves
|
||||
your phone before that. The relay, the network's first node,
|
||||
keeps it at exactly this address and mirrors it on, sealed with
|
||||
its own key on your behalf; a key of your own comes with the app.
|
||||
action: /shape/{voice.key}
|
||||
consequence: [Keep it here]
|
||||
record_as: redoal_voices
|
||||
encouragements:
|
||||
- It stays where you left it.
|
||||
features:
|
||||
- name: ""
|
||||
description: >
|
||||
Up to a minute. Record, listen back, record again if you like
|
||||
— it is sealed as it is once you keep it.
|
||||
icon: lucide:mic
|
||||
requirements:
|
||||
- name: voice
|
||||
label: Voice
|
||||
type: voice
|
||||
relay: wss://relay.redoal.com
|
||||
value: "{key}"
|
||||
optional: true
|
||||
|
||||
- name: Hear from this address
|
||||
description: >
|
||||
The fallback for a phone without the app: one note when
|
||||
something is left at exactly this shape — and nothing about
|
||||
anywhere else.
|
||||
action: /subscribed
|
||||
consequence: [Watch this address]
|
||||
record_as: redoal_watchers
|
||||
encouragements:
|
||||
- Your shape now has a listener.
|
||||
features:
|
||||
- name: Where the note goes
|
||||
description: >
|
||||
The note is about one exact address, so it needs one exact
|
||||
place to land. One mail comes first, asking you to confirm —
|
||||
nothing is sent before you do, and at most one note a day
|
||||
after.
|
||||
icon: lucide:mail
|
||||
requirements:
|
||||
- name: email
|
||||
label: Email
|
||||
type: email
|
||||
- name: key
|
||||
label: Address
|
||||
type: hidden
|
||||
value: "{key}"
|
||||
|
||||
- name: What just happened
|
||||
description: ""
|
||||
features:
|
||||
- name: An address, not an upload
|
||||
description: >
|
||||
The stroke became a 20-byte gesture key — an ordered chain of
|
||||
headings, drawn back for you under the stroke. Similar shapes
|
||||
land on nearby keys; picking a kept place makes its key yours.
|
||||
- name: Nothing to undo
|
||||
description: >
|
||||
A drawn path is a fact, not an account. There is no profile
|
||||
behind it unless you asked to hear from an address.
|
||||
|
||||
- name: Draw another
|
||||
description: >
|
||||
The network gets more interesting with every shape it hears.
|
||||
action: /
|
||||
consequence: [Back to the canvas]
|
||||
@@ -21,3 +21,12 @@ alternatives:
|
||||
bucket: redoal_subscribers
|
||||
to: unsubscribed
|
||||
label: Unsubscribe me
|
||||
|
||||
- name: Stop watching an address
|
||||
description: >
|
||||
Using the link from a note about a shape? Confirm below and that
|
||||
address goes quiet for you.
|
||||
self_transition:
|
||||
bucket: redoal_watchers
|
||||
to: unsubscribed
|
||||
label: Stop watching
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Landing page of the confirmation mail: /watching?chain=<record>&email=<address>.
|
||||
# A followup - never in the nav; the record id in the link is the only
|
||||
# way here, and it plus the matching email is what authorizes the
|
||||
# transition (portal's self_transition).
|
||||
followup: true
|
||||
name: Are you watching this address?
|
||||
description: >
|
||||
Someone asked to hear from a shape on redoal with your address. Say
|
||||
yes and you get one note when something is left there — nothing
|
||||
else, ever. Say it wasn't you and the request is dropped.
|
||||
alternatives:
|
||||
- name: Yes, watch it
|
||||
description: >
|
||||
One note per day at most, only when a recording or a program
|
||||
lands at exactly this shape.
|
||||
self_transition:
|
||||
bucket: redoal_watchers
|
||||
to: open
|
||||
label: Yes, watch it
|
||||
|
||||
- name: That wasn't me
|
||||
description: >
|
||||
The request is dropped and the address stays unused.
|
||||
self_transition:
|
||||
bucket: redoal_watchers
|
||||
to: unsubscribed
|
||||
label: Drop it
|
||||
Reference in New Issue
Block a user