Compare commits

...
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 064c96f5ab Announce the group exhibition at Podium
Lint and reload / lint (pull_request) Successful in 2s
Lint and reload / reload (pull_request) Skipped
Opens 21 October 2026, runs a month. Uses portal's event: window so
it rides in the header while it runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:33:45 +02:00
Bendik Aagaard Lynghaug 3d229754bf Merge main
Lint and reload / lint (pull_request) Successful in 2s
Lint and reload / reload (pull_request) Skipped
2026-08-30 14:29:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 588e35fd44 Hero: every path moves on its own, and the tip glides
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
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 <noreply@anthropic.com>
2026-08-30 14:29:39 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ecd77fae35 Trial event page retired - announced pages verified live
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:28:46 +02:00
Bendik Aagaard Lynghaug 9a56a0fe8b Trial event: second window for the header capture
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-08-30 14:27:59 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 79ab3b37b0 Review desk: announced pages; trial event page
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:37:18 +02:00
Bendik Aagaard Lynghaug f4e9db8475 Pin portal v0.3.6 - announced pages
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-08-30 12:36:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 758b046049 Drop the manifest-language card and the program mention
Lint and reload / lint (pull_request) Successful in 2s
Lint and reload / reload (pull_request) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:18:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 039d2bf54d Deploy enables the instance unit
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
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 <noreply@anthropic.com>
2026-08-30 12:09:14 +02:00
Bendik Aagaard Lynghaug 60050d80ae Pin portal v0.3.5 - gesture ghost for v2 keys
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
2026-08-30 11:43:10 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 818c56dd09 Drop the self-reconstruction claim
Lint and reload / lint (pull_request) Successful in 3s
Lint and reload / reload (pull_request) Skipped
decode_key's sketch is too rough to be worth advertising.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:21:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bb0eeb58b1 Say what each card enables
Lint and reload / lint (pull_request) Successful in 3s
Lint and reload / reload (pull_request) Skipped
Every talking point now leads with the capability instead of the
absence: the room answers, the seal is yours, drawing and speaking
is the entry requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:13:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3c2f69a290 Landing page: talking points drawn from the zine
Lint and reload / lint (pull_request) Successful in 3s
Lint and reload / reload (pull_request) Skipped
A new alternative, "Meet a network with no list and no landlord",
carries seven feature cards lifted from the redoal repo's zine and
intro-video script: the drawing-as-address trick, no middleman,
voices at a place, signatures not names, the twenty-four-word
manifest language, the no-reading entry requirement, and the honest
status. The landing description opens with the cold-open line, and
/shape gains a card on what a place holds in the full network.

Nothing here overclaims: every card describes something the redoal
repo ships and tests, and the last one says what doesn't yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:12:01 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 429d3a5bff Pin portal v0.3.4 - disabled alternatives
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:28:14 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 5b04c12ce8 Find what follows: announced, not yet open (disabled: true)
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:25:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 f844ec02eb Pin portal v0.3.3 - empty-list notice
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:11:22 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 dbc44956d3 Pin portal v0.3.2 - resource empty text
Deploy instance / deploy (push) Successful in 0s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:07:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8c79787d87 Releases: say so when nothing is released yet
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:05:21 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 83f7533c21 Pin portal v0.3.1 - content-hashed assets (LEPTOS_HASH_FILES=true)
Deploy instance / deploy (push) Successful in 0s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:51:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9a5ebb6715 Hero copy points below, where the canvas now is
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:28:31 +02:00
Bendik Aagaard Lynghaug e6f847b7d3 hero.js: don't shadow the global CSS object 2026-08-25 19:27:37 +02:00
Bendik Aagaard Lynghaug 683c6da6b9 Hero: lysbue's paths paint worklet, animated (WIP branch) 2026-08-25 19:26:54 +02:00
9 changed files with 467 additions and 102 deletions
+12 -1
View File
@@ -18,7 +18,7 @@ on:
- .gitea/workflows/deploy.yml
env:
PORTAL_RELEASE: v0.3.0
PORTAL_RELEASE: v0.3.6
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
+9 -5
View File
@@ -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
+121 -93
View File
@@ -1,66 +1,56 @@
// 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 } 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';
// (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 = `
.redoal-paths {
position: absolute;
inset: 0;
overflow: hidden;
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 +58,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);
}
+32
View File
@@ -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]);
}
},
);
+167
View File
@@ -0,0 +1,167 @@
// 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.
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) * 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();
}
+37
View File
@@ -0,0 +1,37 @@
# The group exhibition at Podium, connected to the conference: opens
# 21 October 2026 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 21 October
and running for a month alongside the conference. Draw on the
screens, leave a voice at a shape, hear who came before you.
event:
starts: 2026-10-21T18:00:00+02:00
duration: 31d
place: Podium, 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 21 October
description: >
The exhibition opens on the 21st and stays up for a month,
within the conference programme. Come to the opening, or
any day after.
icon: lucide:calendar-days
- 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
+60 -3
View File
@@ -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
@@ -14,6 +15,9 @@ alternatives:
discover signal within chaos. Draw a path — the network answers
with the shapes it heard nearby.
action: /shape
# Announced, not yet open: the network answers with echoes today,
# what follows a shape is still being built.
disabled: true
consequence: [Find what follows]
record_as: redoal_gestures
encouragements:
@@ -37,6 +41,58 @@ alternatives:
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: Young, and honest about it
description: >
A prototype and an artwork before it is a product. The
gesture math, the fingerprints, the voice recordings, and the
machine-to-machine fetching are real and running; the
transport that reaches across the internet is being built
now. The edges are rough, and the edges are where the
interesting questions live.
icon: lucide:sprout
- name: Follow the build as it lands
description: >
The network as it is being built — and we need collaborators on
@@ -57,6 +113,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
+23
View File
@@ -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
+6
View File
@@ -18,6 +18,12 @@ alternatives:
description: >
A drawn path is a fact, not an account. There is no profile
behind it unless you left an email.
- name: What arrives here next
description: >
In the full network a drawing is a place that holds voices —
recordings left by whoever came before, and live talk with
whoever is standing here now. On this site it holds echoes
today; the voices follow.
- name: Draw another
description: >