Compare commits

..
48 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug ff57b15f59 Pin portal v0.3.32
Deploy instance / deploy (push) Successful in 0s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-09-01 22:15:11 +02:00
Bendik Aagaard Lynghaug 0d20770c50 Pin portal v0.3.31
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-09-01 21:51:02 +02:00
Bendik Aagaard Lynghaug fb73011d0c Pin portal v0.3.30
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-09-01 21:45:07 +02:00
Bendik Aagaard Lynghaug 9684146dd8 Pin portal v0.3.29
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-09-01 21:39:39 +02:00
Bendik Aagaard Lynghaug b8fc2ade6c Pin portal v0.3.28
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
2026-09-01 20:33:31 +02:00
Bendik Aagaard Lynghaug 479bc72b6e Pin portal v0.3.27
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
2026-09-01 20:29:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 cb2d9760a1 Pin portal v0.3.24
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>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:03:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0bfdc0b45e Pin portal v0.3.23
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>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:26:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c74fc6d708 Pin portal v0.3.21
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>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:13:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b4f12a1a6a Pin portal v0.3.18
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>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:01:04 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b34be2ada9 Pin portal v0.3.17
Deploy instance / deploy (push) Successful in 1s
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>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-31 17:22:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 f2dbd7aabd Pin portal v0.3.16
Deploy instance / deploy (push) Successful in 1s
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>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:16:01 +02:00
Bendik Aagaard Lynghaug d770d8a137 Pin portal v0.3.14 - icon column
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 16:58:20 +02:00
Bendik Aagaard Lynghaug 3dab10d6e4 Pin portal v0.3.13 - voice keeps on submit
Deploy instance / deploy (push) Successful in 0s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-08-30 16:38:16 +02:00
Bendik Aagaard Lynghaug cba804adca Pin portal v0.3.12
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 16:08:59 +02:00
Bendik Aagaard Lynghaug eabde0c42c Pin portal v0.3.11 - templated action lint fix
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 16:04:58 +02:00
Bendik Aagaard Lynghaug 4a1bfd06c2 Starred repos: leave the star counts out for now
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
2026-08-30 16:00:53 +02:00
Bendik Aagaard Lynghaug 7859be90f5 Pin portal v0.3.10 - voice, places
Lint and reload / lint (push) Successful in 3s
Deploy instance / deploy (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
2026-08-30 16:00:06 +02:00
Bendik Aagaard Lynghaug 8a291fb574 README: voice, value, url substitution, audio cards
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-08-30 15:58:24 +02:00
Bendik Aagaard Lynghaug f64d62f20e Pin portal v0.3.9 - templated actions, pickable places
Lint and reload / reload (push) Successful in 0s
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
2026-08-30 15:42:11 +02:00
Bendik Aagaard Lynghaug dd858e4ee6 README: markdown descriptions
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 1s
2026-08-30 14:47:22 +02:00
Bendik Aagaard Lynghaug 51fae8b86c Pin portal v0.3.8 - markdown descriptions
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Deploy instance / deploy (push) Successful in 1s
2026-08-30 14:46:59 +02:00
Bendik Aagaard Lynghaug 83af980ab1 Pin portal v0.3.7 - feature links
Lint and reload / lint (push) Successful in 2s
Deploy instance / deploy (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
2026-08-30 14:42:00 +02:00
Bendik Aagaard Lynghaug e8a04a2783 README: Feature.link
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
2026-08-30 14:39:43 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 799e2b3c8c Review desk: announced pages (portal_events)
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-30 12:37:17 +02:00
Bendik Aagaard Lynghaug 16e5a27bde Pin portal v0.3.6 - announced pages
Lint and reload / lint (push) Successful in 2s
Deploy instance / deploy (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
2026-08-30 12:36:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1bb73aa37a Deploy enables the instance unit
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
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 1dcf8118d2 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 717c8ee365 README: the proposal loop as it now runs (draft PR on submission)
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 22:52:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 17112452b8 Proposal file select lists the whole questions tree
Lint and reload / reload (push) Successful in 0s
Lint and reload / lint (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:48:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 17341c170e Propose it: announced, not yet open (disabled: true)
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 22:45:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 569a155015 Pin portal v0.3.4 - disabled alternatives
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:28:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 a5125e191a README: Alternative.disabled
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:25:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 951e85db33 Pin portal v0.3.3 - empty-list notice
Lint and reload / lint (push) Successful in 3s
Deploy instance / deploy (push) Successful in 1s
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 564929db00 Pin portal v0.3.2 - resource empty text
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:07:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 36e86c7787 README: ResourceSpec.empty
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:05:22 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 58b03a222e Pin portal v0.3.1 - content-hashed assets (LEPTOS_HASH_FILES=true)
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 19:51:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 87d004cc05 Ship the hero as content: hero.js + site.yaml; docs
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:28:45 +02:00
Bendik Aagaard Lynghaug 56ffc87786 Merge branch 'hero-module' 2026-08-25 17:28:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 e38b3302f1 Pin portal v0.3.0 - content-owned hero modules
Deploy instance / deploy (push) Successful in 1s
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 17:28:34 +02:00
Bendik Aagaard Lynghaug 4720397450 Content-owned hero module (WIP branch for local verification)
Lint and reload / lint (push) Skipped
2026-08-25 17:24:14 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d928fbcbc9 Pin portal v0.2.4 - gates keep the nav
Lint and reload / lint (push) Successful in 3s
Deploy instance / deploy (push) Successful in 0s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:07:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 981a448b30 Icons on every input feature; why-lines; question titles; voice rule
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Desk/receipt titles become questions (What's been proposed? / What
happens to your proposal? / What came in? / Which proposal is this?),
Where to send it and Record a prospect explain why before the field,
lucide icons on input-bearing features, README's alternatives rule
reworded (draw people in with a real path, not necessarily a slogan)
and its stale /develop-proposal + followup references fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:04:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bd19da9959 Pin portal v0.2.3 - attended-bucket lint, first-nav fix; document the rule
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:38:08 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1ff3c971ad Pin portal v0.2.2 - single app-level title
Deploy instance / deploy (push) Successful in 1s
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-24 22:49:14 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 f25dedd742 Pin portal v0.2.1 - asset cache revalidation, stable title on nav
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-24 22:43:14 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 f243494499 Pin portal v0.2.0; document tree routing in the schema reference
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-24 22:33:16 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ad9dd1762b The tree is the router: nest the develop flow, section-gate review
develop/ holds the whole proposal flow (index = owners desk,
proposal = public form, proposed = chain-gated receipt) with relative
actions; review/ gains _section.yaml (one owners gate for the
directory) and a [record].yaml dynamic page - /review/<chain-hash>
shows a single proposal. Top-level files drop their now-derived id:
and fossil route: fields. URLs /develop-proposal and /proposed become
/develop/proposal and /develop/proposed (no automation references
them - n8n filters checked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:32:01 +02:00
14 changed files with 946 additions and 61 deletions
+12 -1
View File
@@ -18,7 +18,7 @@ on:
- .gitea/workflows/deploy.yml
env:
PORTAL_RELEASE: v0.1.0
PORTAL_RELEASE: v0.3.32
INSTANCE: uhhm-portal
jobs:
@@ -68,6 +68,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
AUTOMATION_READ_TOKEN=${{ secrets.AUTOMATION_READ_TOKEN }}
# Read-only (read:user,read:repository,read:organization),
# used only by the Gitea resource sources (portal
@@ -81,9 +85,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
# Apex and www are separate cookie scopes (no shared Domain
+157 -31
View File
@@ -44,7 +44,7 @@ itself. `.gitea/workflows/deploy.yml` pins the portal version:
```yaml
env:
PORTAL_RELEASE: v0.1.0 # a release tag on uhhm/portal
PORTAL_RELEASE: v0.2.3 # a release tag on uhhm/portal
```
Upgrading (or downgrading) portal is bumping that line and pushing —
@@ -132,14 +132,19 @@ entirely in this repo, in this order:
infrastructure repo's `n8n-workflows/`). Rename a page or a label
and its workflow must be updated in lockstep.
5. **Change the system through itself.** `/develop-proposal` takes a
proposed replacement for any content file (`questions/*.yaml` or
`aggregates.yaml` — nothing else is in scope) — from a person, or
eventually a locally-run model, through the same public form. An
owner approves it on `/develop`; approval triggers the automation
that branches, commits, opens a PR, and merges only when the same
lint that gates every human push passes. Nothing merges on
autopilot.
5. **Change the system through itself.** `/develop/proposal` takes a
proposed replacement for any content file (the `questions/` tree,
`aggregates.yaml`, or `site.yaml` — nothing else is in scope) —
from a person, or eventually a locally-run model, through the same
public form. The submission itself becomes a branch and a draft
pull request at once (n8n "Portal: proposal received"), so lint
reports on it immediately and the submitter gets one
acknowledgement mail if they left an address. An owner approves it
on `/develop`; approval un-drafts the PR and merges it once the
same lint that gates every human push passes ("Portal: commit
approved development proposal"). Nothing merges on autopilot. The
alternative is `disabled: true` while this loop is being finished —
announced, not yet open.
## Voice
@@ -147,9 +152,11 @@ The rules every page here follows:
- **The title is the only question.** A page asks one thing — its
`name`. Nothing below it asks anything.
- **Alternatives are bold statements**, argued against each other —
positions we'd like a visitor to get behind, in our voice, never
the visitor's presumed voice and never a question.
- **Alternatives draw people in.** Each one suggests a real path a
visitor could take — a position to get behind or a thing to do next,
in our voice, never the visitor's presumed voice and never a
question. A category label ("Our Composition") is not an
alternative; "Follow the build as it lands" is.
- **Explain why, then hand over the field.** Where input is needed,
the feature's name and description state the reason ("the reply is
personal — it needs an address"), and labels are nouns, not
@@ -159,7 +166,7 @@ The rules every page here follows:
that gives them an answer — not segments to self-select into. The
question nav (every page's footer) surfaces all questions the
current visitor qualifies for, so nothing depends on front-page
space; post-submission pages carry `followup: true` and only join
space; post-submission pages (nested non-index files, or `followup: true`) only join
the nav once the visitor holds an answer chain.
## Schema quick reference
@@ -167,11 +174,17 @@ The rules every page here follows:
The exact structs live in portal's `src/content.rs`; the shape:
```
Question id (doubles as the URL path), name, description,
qualifies (Kanidm group gate), followup (nav-hidden
until the visitor carries a chain),
responsible {name, contact}, alternatives[]
Alternative name, description, action, consequence[label],
Question id (derived from the file's path - see routing
below - declare only to override), name,
description, qualifies (Kanidm group gate),
requires_chain (question ref the visitor's ?chain=
lineage must end at), followup (nav-hidden until
the visitor carries a chain; inferred from the
tree when unset), event {starts, duration, place}
(announced page - see below), responsible
{name, contact}, alternatives[]
Alternative name, description, action, disabled (announced,
not yet takeable), consequence[label],
encouragements[], images[] (1 = banner, 2+ = card deck),
record_as (bucket), self_transition {bucket, to, label},
features[]
@@ -183,34 +196,147 @@ Question id (doubles as the URL path), name, description,
bind {field, param, resource} (load this field's
value from a resource whenever the named sibling
changes - {param} templates into a url source's path),
relay (gesture only - ws(s):// redoal-relay URL)
value (preset; on a dynamic page {name} takes the
URL segment - value: "{key}" hands a voice field
its address), relay (gesture/voice - ws(s)://
redoal-relay URL)
ResourceSpec source (kv | gitea_starred | gitea_org_repos |
gitea_releases | url),
key, public, requires_group, transitions[{from, to, label}],
jq (reshape filter)
jq (reshape filter), empty (text when the
resource yields nothing; default "Nothing here yet.")
```
Requirement `type`: `text` (default), `textarea`, `email`, `tel`,
`select`, `file`, `prosekit` (rich text), `gesture` (draw a stroke on
a canvas; submits `{points, key}` - with `relay` set it announces the
stroke to a redoal-relay and shows echoes of similar strokes; mark it
`optional: true`, hidden inputs skip HTML required-validation), or any
`select`, `file`, `prosekit` (rich text), `gesture`, `voice`, or any
HTML input type.
- `gesture` draws a stroke on a canvas and submits `{points, key}`.
With `relay` set it announces the stroke to a redoal-relay: the
relay answers with the key and its decode (drawn under the stroke),
with who is at a similar shape right now, and with *places* - keys
that hold recordings. Picking a place makes its key the field's:
the value becomes `{points, key, own_key, selected_from,
selected_distance}`. Mark it `optional: true` (hidden inputs skip
HTML required-validation).
- `voice` records in the browser and sends the audio to the relay,
which keeps it at the field's `value` key (`value: "{key}"` on a
`/shape/[key]` page). The value becomes `{key, digest, duration_ms}`.
Needs `relay`; `optional: true` for the same reason.
A `[name].yaml` page's segment also substitutes into a `url` resource
source (`https://relay.redoal.com/place/{key}`), and a resource item
with an https `audio` field renders an `<audio>` player.
A repo may also carry an optional `site.yaml` at its root (sibling of
`aggregates.yaml`) declaring instance branding: `title`, `wordmark`
(image URL), and `hero {kind: yes | gesture | plain, relay}` for the
landing page. Absent file = the historical uhhm look (YES canvas,
/wordmark.svg).
(image URL), and the landing page's `hero`:
```yaml
hero:
kind: module # plain (default) | module
module: hero.js # repo-relative path, served by portal at /site/hero.js
```
A `module` hero is a JavaScript module this repo ships, exporting
`mount(container) -> handle` where the handle has `stop()`. Portal
serves it same-origin (Gitea's raw endpoint sends no CORS headers, so
`/site/<path>` proxies any plain-segment path from the repo), starts
it at HTML parse time, adopts it on hydration, and stops it on
navigation. Everything visual is the module's - it builds its own DOM
inside `.hero-piece` and may inject its own stylesheet, including
rules for the `.hero-module` header itself. uhhm's `hero.js` is the
YES canvas piece; redoal's is the sine-swings band. Absent file =
plain hero, portal's `SITE_NAME`, `/wordmark.svg`.
`self_transition` is the anonymous, single-record counterpart to a
review resource's transitions — fireable by whoever holds one specific
record's `?chain=` link plus its matching email (the unsubscribe
pattern).
**Descriptions are inline markdown** (alternative and feature):
`[text](https://…)` links, `*emphasis*`, `**strong**`, `` `code` ``.
One paragraph - block structure flattens; raw HTML is dropped; link
targets may be https, mailto or a site-relative path, anything else
renders as plain text.
**Announced pages.** A question carrying an `event:` block
```yaml
event:
starts: 2026-09-12T18:00:00+02:00 # RFC 3339, with offset
duration: 3h # m / h / d, e.g. "1d 6h"
place: Galleri X, Oslo # optional, shown verbatim
```
is announced in the header of every page (name, when, "in 3 days")
while its window is open, instead of listed in the footer nav. The
page itself is an ordinary question - RSVP, directions, whatever its
alternatives say. When the window closes the page turns into a
followup (only visitors carrying an answer chain still see it) and
portal moves the page's record in the runtime-owned `portal_events`
bucket from `announced` to `awaiting_summary`, publishing that on
`portal.answers.submitted` like any decision (alternative "Summary
due") - the "post what happened" task. `/review`'s "Announced pages"
desk reads that bucket; **Posted what happened** closes the task.
Lint warns when pages carry `event:` but nothing reads
`portal_events`.
**Attended buckets** (lint-enforced): every `record_as` bucket must be
read by some `kv` resource in this repo (a desk or listing) — or its
`aggregates.yaml` entry must carry `attended_by: <who/what consumes
it>` naming the automation that does. A bucket nothing reads fails
lint, so no publicly collected answer can land where no one will ever
see it.
## Routing: the tree is the router
The `questions/` directory tree is the URL tree — `index.yaml` names
its directory, everything else appends its stem:
```
questions/
index.yaml /
applied.yaml /applied
develop/
index.yaml /develop
proposal.yaml /develop/proposal
proposed.yaml /develop/proposed
review/
_section.yaml (not a page - defaults for the directory)
index.yaml /review
[record].yaml /review/<any value>
```
- `id:` is derived from the path; declaring it still wins (legacy),
with a lint warning when it disagrees.
- `action:` and `requires_chain:` take relative refs — `proposed`
names a sibling, `../x` climbs, `/x` is absolute. A directory is a
self-contained flow: `git mv` renames every internal edge with it.
- Files nested in a subdirectory infer `followup: true` unless
they're the directory's `index.yaml` — declare `followup: false`
on a nested page that should stay in the nav. Top-level files keep
the flat-repo default (not a followup).
- `_section.yaml` applies `qualifies`, `requires_chain`, and
`responsible` to every page at or below its directory (nearest
ancestor wins; a page's own declaration always overrides). A URL
prefix is a trust boundary.
- Any other `_`-prefixed file is skipped entirely — drafts live in
the tree without being served.
- `[name].yaml` is a dynamic page: it serves every `/dir/<value>`,
with the segment substituted into `{name}` placeholders in the
page's resource `key`s (`/review/<chain-hash>` shows that one
record). One per directory; never in the nav; not a valid `action`
target.
- `requires_chain: <ref>` gates a page on provenance instead of
identity: the visitor's `?chain=` lineage must verifiably end at an
answer to the referenced question, otherwise the page renders a
pointer there instead of its alternatives.
## Adding a page
Drop a `questions/<name>.yaml` with a unique `id`, reference that `id`
from some alternative's `action`, push. Lint runs, portal hot-reloads,
the page is live — no registration, no deploy. Or propose it through
`/develop-proposal` and let the loop do the pushing.
Drop a YAML file where its URL should live (`questions/foo.yaml`
`/foo`, `questions/flow/step.yaml` → `/flow/step`), reference it from
some alternative's `action`, push. Lint runs, portal hot-reloads, the
page is live — no registration, no deploy. Or propose it through
`/develop/proposal` and let the loop do the pushing.
+688
View File
@@ -0,0 +1,688 @@
// uhhm.no's hero: "YES - Rasterized Lines", the piece that has been
// live at uhhm.no since the static site (ported from
// ~/repos/webpage/content/visualize/ah.html). Content-owned: portal
// knows only site.yaml's `hero: {kind: module, module: hero.js}` and
// the contract below - it serves this file same-origin at
// /site/hero.js, starts it at HTML parse time, adopts it on hydration,
// and calls stop() when the page is left.
//
// Contract: `export function mount(container) -> handle`, handle has
// `stop()`. Everything visual - the canvases, the sticky full-viewport
// header, the palette - is this module's, including the stylesheet it
// injects once below (selectors target portal's .hero-module header
// and .hero-piece box).
//
// Canvas paint can't read CSS custom properties, so THEMES mirrors
// the portal stylesheet's palette for both color schemes - a palette
// change lands there AND here.
const STYLE_ID = 'uhhm-hero-style';
const CSS = `
.hero-module {
/* Sticky at the viewport top for the whole scroll (its containing
block is the page itself), so the piece stays animating behind
everything that follows - the translucent cards scroll over it
and it shows through them and in the gaps around them. z-index 0
so positioned content below can stack above with z-index 1. */
position: sticky;
top: 0;
z-index: 0;
max-width: none;
width: 100%;
/* svh here is only the pre-JS/no-JS fallback (and first paint before
yes.js's constructor runs). Once yes.js mounts, it overwrites this
with an inline \`height: <px>\` frozen from a single measurement -
see setupCanvas()'s comment in yes.js for why: on real mobile
Safari, content bottom-aligned inside this box kept sliding down
as the address bar collapsed even with a spec'd-stable viewport
unit here, so the box's actual height can't be trusted to stay
put on that unit alone. An inline style set from JS always wins
the cascade over this rule, so that frozen number is what
actually governs once the page is interactive.
Plain-vh fallback declared first - an engine without svh support
ignores the invalid second line rather than falling through to
auto height, which would collapse this to the height of its
in-flow content and clip the canvas via overflow:hidden below. */
height: 100vh;
height: 100svh;
padding: 0;
justify-content: flex-end;
overflow: hidden;
}
.hero-module .hero-copy {
position: relative;
z-index: 1;
padding: 0 1.5rem 3.5rem;
gap: 0.6rem;
text-shadow: 0 0.12em 1.4em var(--hero-glow);
}
.hero-canvas {
position: absolute;
inset: 0;
}
.hero-canvas canvas {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
}
.hero-canvas #rasterCanvas {
mix-blend-mode: overlay;
opacity: 0.5;
}
/* The piece wants the whole viewport, not portal's 55svh reservation:
the canvases position against the sticky header itself. */
.hero-module .hero-piece {
position: static;
min-height: 0;
}
@media (prefers-color-scheme: light) {
/* overlay against near-white paper resolves to ~white and the
raster ghost vanishes; multiply lets the light theme's gray YES
show as ink. */
.hero-canvas #rasterCanvas {
mix-blend-mode: multiply;
}
}
`;
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
class RasterizedYES {
constructor(container) {
ensureStyle();
// The piece builds its own DOM inside portal's box - two
// stacked canvases, raster ghost under the drifting lines.
const wrap = document.createElement('div');
wrap.className = 'hero-canvas';
this.lineCanvas = document.createElement('canvas');
this.lineCanvas.id = 'lineCanvas';
this.rasterCanvas = document.createElement('canvas');
this.rasterCanvas.id = 'rasterCanvas';
wrap.append(this.lineCanvas, this.rasterCanvas);
container.appendChild(wrap);
this.wrap = wrap;
// The Rust side only constructs this once it's confirmed (via a
// NodeRef) that the canvas is mounted, but that guard has shown
// a real gap on fast client-side re-navigation back to `/` -
// this is the actual failure point, so it gets its own defense
// rather than depending on getting that timing exactly right
// from the other side of the wasm boundary. Leaving the
// instance otherwise-inert (no crash, no animation) rather than
// throwing mid-render - `stop()` already tolerates a partially
// (non-)initialized instance.
if (!this.rasterCanvas || !this.lineCanvas) {
console.warn('RasterizedYES: canvas not in DOM yet, skipping');
this.destroyed = true;
return;
}
this.rasterCtx = this.rasterCanvas.getContext('2d');
this.lineCtx = this.lineCanvas.getContext('2d');
this.lines = [];
this.rasterData = null;
this.isActive = true;
this.destroyed = false;
this.time = 0;
this.containmentStrength = 0.5;
this.wiggleAmount = 0.5;
// .hero-canvas is inset:0 inside this - freezing an inline
// height here (below) is what actually locks the box, not
// just reading its rect.
this.heroEl = container.closest('.hero');
this.setupCanvas();
this.setupResizeHandler();
this.setupDrift();
this.setupTheme();
this.setupScrollFade();
this.setupClickHandler();
this.rasterizeText();
this.initializeLines();
this.animate();
}
stop() {
this.destroyed = true;
if (this.wrap) this.wrap.remove();
if (this.heroEl) this.heroEl.style.height = '';
if (this.heroCopy) { this.heroCopy.style.opacity = ''; this.heroCopy.style.visibility = ''; }
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
}
if (this._themeQuery) {
this._themeQuery.removeEventListener('change', this._themeHandler);
}
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler);
}
}
// The hero is position: sticky (main.css), so without this the
// title/wordmark stay pinned at the viewport bottom for the whole
// page and ghost through the translucent cards scrolling over
// them. Fade the copy out across the first half-screen of scroll;
// visibility: hidden at the end so the wordmark link can't be
// clicked while invisible.
setupScrollFade() {
this.heroCopy = this.heroEl ? this.heroEl.querySelector('.hero-copy') : null;
if (!this.heroCopy) return;
this._scrollHandler = () => {
const opacity = Math.max(0, 1 - window.scrollY / (this.displayHeight * 0.5));
this.heroCopy.style.opacity = opacity;
this.heroCopy.style.visibility = opacity <= 0.01 ? 'hidden' : '';
};
window.addEventListener('scroll', this._scrollHandler, { passive: true });
this._scrollHandler();
}
// Canvas paint can't read CSS custom properties, so the piece
// carries its own copy of both palettes and follows
// prefers-color-scheme itself - values must track main.css's :root
// (--paper especially: the fade fill IS the page background where
// the canvas shows through translucent cards). Dark keeps the
// original neon-on-black inks; light restates them as CMYK process
// inks dark enough to carry on paper, since 80%-lightness pastels
// vanish on white.
setupTheme() {
this._themeQuery = window.matchMedia('(prefers-color-scheme: light)');
this._themeHandler = () => {
this.applyTheme();
// Repaint the raster ghost in the new theme's ink and
// hard-clear the trails - a slow 3%-alpha fade from the
// old paper color would smear across the flip otherwise.
this.rasterizeText();
this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
};
this._themeQuery.addEventListener('change', this._themeHandler);
this.applyTheme();
}
applyTheme() {
this.theme = this._themeQuery.matches
? {
paper: '#f6f5f1',
fade: 'rgba(246, 245, 241, 0.03)',
// White ground so multiply (the light theme's CSS
// blend mode for #rasterCanvas) leaves the paper
// untouched; gray ink becomes the faint YES ghost.
rasterBg: '#ffffff',
rasterInk: '#6b6b6b',
strokes: [
'hsla(185, 70%, 32%, 0.85)',
'hsla(315, 60%, 38%, 0.85)',
'hsla(50, 90%, 40%, 0.85)'
]
}
: {
paper: '#0a0a0a',
fade: 'rgba(10, 10, 10, 0.03)',
rasterBg: '#111111',
rasterInk: '#ffffff',
strokes: [
'hsla(180, 90%, 80%, 0.8)',
'hsla(300, 90%, 80%, 0.8)',
'hsla(60, 90%, 80%, 0.8)'
]
};
}
// Reading .hero-canvas's rendered rect (a prior attempt at this)
// only helps if that rect actually stays put - and on real mobile
// Safari it didn't: .hero-copy (bottom-aligned via flexbox inside
// .hero-yes) kept sliding down as the address bar collapsed, even
// with a spec'd-stable viewport unit (svh, then lvh) driving the
// box's height. Either the browser isn't holding up its end, or
// something in the cascade is still landing on a live value - not
// provable from here without the device. Rather than keep
// guessing at which CSS viewport unit actually holds still, yes.js
// becomes the source of truth instead: it freezes .hero-yes's
// rendered height to a literal inline px value once, up front. An
// inline style always wins the cascade over the stylesheet's
// `height: 100svh`, so once this runs, nothing the browser does
// with that unit afterward can move the box - the number is fixed
// in the DOM, not recomputed from a unit at all.
setupCanvas() {
const pixelRatio = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
if (this.heroEl) {
this.heroEl.style.height = height + 'px';
}
this.rasterCanvas.style.width = width + 'px';
this.rasterCanvas.style.height = height + 'px';
this.lineCanvas.style.width = width + 'px';
this.lineCanvas.style.height = height + 'px';
this.rasterCanvas.width = width * pixelRatio;
this.rasterCanvas.height = height * pixelRatio;
this.lineCanvas.width = width * pixelRatio;
this.lineCanvas.height = height * pixelRatio;
this.rasterCtx.scale(pixelRatio, pixelRatio);
this.lineCtx.scale(pixelRatio, pixelRatio);
this.displayWidth = width;
this.displayHeight = height;
this.pixelRatio = pixelRatio;
}
setupResizeHandler() {
// Gate on width, not height: mobile Safari's address-bar
// animation changes window.innerHeight continuously with no
// real layout change to react to (that's the live value this
// whole method exists to stop trusting). A genuine resize -
// orientation change, desktop window drag - always changes
// width too, so that's the real signal to re-freeze on.
this._resizeHandler = () => {
if (window.innerWidth === this.displayWidth) return;
this.setupCanvas();
this.rasterizeText();
};
window.addEventListener('resize', this._resizeHandler);
}
// The two behavior dials (containmentStrength from x, wiggleAmount
// from y) used to follow the pointer. Now a smooth noise field
// wanders them instead - the same 0..1 inputs a mouse would give,
// but drifting at cloud pace, so the piece breathes on its own and
// behaves identically with nobody touching it (which on a landing
// hero is most of the time, and on touch devices was always the
// case between taps). Value noise with two octaves: smooth
// (C1-continuous via smoothstep), never repeats visibly, no jumps.
setupDrift() {
const channel = (seed) => {
const rand = (i) => {
let h = Math.imul(i ^ seed, 2654435761) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 0x5bd1e995) >>> 0;
// The >>> 0 here is load-bearing: ^ yields a SIGNED
// 32-bit value, and without the reinterpret a set top
// bit made this "0..1" noise go as low as -0.5,
// pushing both dials below their intended floors.
h = (h ^ (h >>> 15)) >>> 0;
return h / 4294967296;
};
const noise = (t) => {
const i = Math.floor(t);
const f = t - i;
const s = f * f * (3 - 2 * f);
return rand(i) * (1 - s) + rand(i + 1) * s;
};
// Two octaves, renormalized to 0..1: the slow octave sets
// the overall weather, the faster one keeps it from
// feeling like a pendulum.
return (t) => (noise(t) * 2 / 3 + noise(t * 2.7 + 913) * 1 / 3);
};
// One full "weather change" roughly every DRIFT_PERIOD
// seconds per octave - the pace of watching clouds, not of a
// hand on a mouse.
this.driftPeriod = 25;
this.driftX = channel(0x9e3779b9);
this.driftY = channel(0x85ebca6b);
this.containmentStrength = 0.55;
this.wiggleAmount = 1.05;
}
updateDrift() {
const t = this.time / this.driftPeriod;
const x = this.driftX(t);
const y = this.driftY(t);
// Same mapping the mouse position used to feed.
this.containmentStrength = 0.1 + (x * 0.9);
this.wiggleAmount = 0.1 + (y * 1.9);
}
setupClickHandler() {
this.lineCanvas.addEventListener('click', () => {
this.restartAnimation();
});
}
restartAnimation() {
this.time = 0;
this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
this.initializeLines();
this.isActive = true;
}
rasterizeText() {
const width = this.displayWidth;
const height = this.displayHeight;
const aspectRatio = width / height;
let fontSize;
if (aspectRatio < 1) {
fontSize = width * 0.4;
} else {
fontSize = height * 0.5;
}
this.fontSize = fontSize;
this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`;
this.rasterCtx.textAlign = 'left';
this.rasterCtx.textBaseline = 'middle';
const fullTextMetrics = this.rasterCtx.measureText('YES');
const textWidth = fullTextMetrics.width;
const textStartX = (width - textWidth) / 2;
const textY = height / 2;
const letters = ['Y', 'E', 'S'];
this.letterPositions = [];
let currentX = textStartX;
for (let i = 0; i < letters.length; i++) {
const letterMetrics = this.rasterCtx.measureText(letters[i]);
this.letterPositions[i] = {
x: currentX,
y: textY,
width: letterMetrics.width,
centerX: currentX + letterMetrics.width / 2
};
currentX += letterMetrics.width;
}
this.letterRasters = [];
for (let i = 0; i < 3; i++) {
this.rasterCtx.fillStyle = '#111';
this.rasterCtx.fillRect(0, 0, width, height);
this.rasterCtx.fillStyle = '#ffffff';
this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y);
this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
}
// The visible layer, distinct from the letterRasters sampled
// above (those stay #111/#fff - isInSpecificLetter's red>128
// test depends on it): painted in theme ink so the ghost works
// under the theme's blend mode (overlay on dark, multiply on
// light - see #rasterCanvas in main.css).
this.rasterCtx.fillStyle = this.theme.rasterBg;
this.rasterCtx.fillRect(0, 0, width, height);
this.rasterCtx.fillStyle = this.theme.rasterInk;
this.rasterCtx.fillText('YES', textStartX, textY);
this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
}
isInSpecificLetter(x, y, letterIndex) {
const canvasX = x * this.pixelRatio;
const canvasY = y * this.pixelRatio;
if (!this.letterRasters || !this.letterRasters[letterIndex] ||
canvasX < 0 || canvasY < 0 ||
canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) {
return false;
}
const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4;
const red = this.letterRasters[letterIndex].data[index];
return red > 128;
}
initializeLines() {
this.lines = [];
const numLines = 60;
const letterCentroids = this.calculateLetterCentroids();
for (let i = 0; i < numLines; i++) {
const letterIndex = Math.floor(i / (numLines / 3));
let startX, startY, centerX, centerY;
if (letterCentroids[letterIndex]) {
centerX = letterCentroids[letterIndex].x;
centerY = letterCentroids[letterIndex].y;
const startVariation = this.fontSize * 0.1;
startX = centerX + (Math.random() - 0.5) * startVariation;
startY = centerY + (Math.random() - 0.5) * startVariation;
if (!this.isInSpecificLetter(startX, startY, letterIndex)) {
const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex);
if (nearestPoint) {
startX = nearestPoint.x;
startY = nearestPoint.y;
} else {
startX = centerX;
startY = centerY;
}
}
} else {
const letterPos = this.letterPositions[letterIndex];
startX = letterPos.centerX;
startY = letterPos.y;
centerX = startX;
centerY = startY;
}
// Stroke color lives on the theme, looked up per frame by
// letterIndex (see draw()) - not frozen per line - so a
// theme flip recolors live lines instead of leaving
// dark-theme neon smearing across light paper.
this.lines.push({
relativeX: (startX - centerX) / this.fontSize,
relativeY: (startY - centerY) / this.fontSize,
prevRelativeX: (startX - centerX) / this.fontSize,
prevRelativeY: (startY - centerY) / this.fontSize,
angle: Math.random() * Math.PI * 2,
letterIndex: letterIndex,
lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize },
outsideDuration: 0
});
}
}
calculateLetterCentroids() {
const centroids = [];
for (let letterIndex = 0; letterIndex < 3; letterIndex++) {
let sumX = 0, sumY = 0, count = 0;
const letterPos = this.letterPositions[letterIndex];
const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1);
const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1);
const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6);
const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6);
const step = Math.max(1, Math.floor(this.fontSize * 0.02));
for (let y = searchStartY; y <= searchEndY; y += step) {
for (let x = searchStartX; x <= searchEndX; x += step) {
if (this.isInSpecificLetter(x, y, letterIndex)) {
sumX += x;
sumY += y;
count++;
}
}
}
if (count > 0) {
centroids[letterIndex] = {
x: sumX / count,
y: sumY / count
};
} else {
centroids[letterIndex] = {
x: letterPos.centerX,
y: letterPos.y
};
}
}
return centroids;
}
updateLines() {
this.time += 0.016;
this.updateDrift();
if (!this.isActive) return;
const letterCentroids = this.calculateLetterCentroids();
this.lines.forEach(line => {
line.prevRelativeX = line.relativeX;
line.prevRelativeY = line.relativeY;
const centroid = letterCentroids[line.letterIndex];
if (!centroid) return;
const currentX = centroid.x + line.relativeX * this.fontSize;
const currentY = centroid.y + line.relativeY * this.fontSize;
const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex);
if (currentlyInside) {
line.outsideDuration = 0;
line.lastSeenInside = { x: line.relativeX, y: line.relativeY };
} else {
line.outsideDuration++;
}
const visionDistance = 0.08 * this.fontSize;
const centerX = currentX + Math.cos(line.angle) * visionDistance;
const centerY = currentY + Math.sin(line.angle) * visionDistance;
const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance;
const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance;
const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance;
const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance;
const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex);
const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex);
const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex);
let speed = 0.02;
const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45);
if (line.outsideDuration > attractionThreshold) {
const targetX = line.lastSeenInside.x;
const targetY = line.lastSeenInside.y;
const deltaX = targetX - line.relativeX;
const deltaY = targetY - line.relativeY;
const angleToTarget = Math.atan2(deltaY, deltaX);
let angleDiff = angleToTarget - line.angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const baseAttraction = Math.min(0.4, line.outsideDuration / 80);
const attractionStrength = baseAttraction * this.containmentStrength;
line.angle += angleDiff * attractionStrength;
}
if (centerSees) {
const baseWiggle = 0.15;
line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount;
} else {
speed *= (0.3 + this.containmentStrength * 0.4);
const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4);
const randomTurnAmount = 0.2 * this.wiggleAmount;
if (leftSees && !rightSees) {
line.angle -= baseTurnStrength + Math.random() * randomTurnAmount;
} else if (rightSees && !leftSees) {
line.angle += baseTurnStrength + Math.random() * randomTurnAmount;
} else {
const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7);
line.angle += randomTurn;
}
}
line.relativeX += Math.cos(line.angle) * speed;
line.relativeY += Math.sin(line.angle) * speed;
line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX));
line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY));
});
}
findNearestSpecificLetterPixel(x, y, letterIndex) {
const searchRadius = this.fontSize * 0.08;
const step = Math.max(1, Math.floor(this.fontSize * 0.01));
let nearestPoint = null;
let nearestDistance = Infinity;
for (let dy = -searchRadius; dy <= searchRadius; dy += step) {
for (let dx = -searchRadius; dx <= searchRadius; dx += step) {
const testX = x + dx;
const testY = y + dy;
if (this.isInSpecificLetter(testX, testY, letterIndex)) {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestPoint = { x: testX, y: testY };
}
}
}
}
return nearestPoint;
}
draw() {
this.lineCtx.fillStyle = this.theme.fade;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
const letterCentroids = this.calculateLetterCentroids();
this.lines.forEach(line => {
const centroid = letterCentroids[line.letterIndex];
if (!centroid) return;
const currentX = centroid.x + line.relativeX * this.fontSize;
const currentY = centroid.y + line.relativeY * this.fontSize;
const prevX = centroid.x + line.prevRelativeX * this.fontSize;
const prevY = centroid.y + line.prevRelativeY * this.fontSize;
if (prevX === currentX && prevY === currentY) return;
this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex];
this.lineCtx.lineWidth = this.fontSize * 0.003;
this.lineCtx.lineCap = 'round';
this.lineCtx.beginPath();
this.lineCtx.moveTo(prevX, prevY);
this.lineCtx.lineTo(currentX, currentY);
this.lineCtx.stroke();
});
}
animate() {
if (this.destroyed) return;
this.updateLines();
this.draw();
requestAnimationFrame(() => this.animate());
}
}
export function mount(container) {
return new RasterizedYES(container);
}
-2
View File
@@ -1,5 +1,3 @@
id: /applied
route: ^/applied
followup: true
name: What happens now that you've applied?
description: >
@@ -1,6 +1,4 @@
id: /develop
route: ^/develop
name: Development proposals
name: What's been proposed?
qualifies: owners
description: >
Proposed changes to the questions repo, awaiting a decision.
@@ -1,5 +1,6 @@
id: /develop-proposal
route: ^/develop-proposal
# Nested for the flow, but a public entry point - without this it
# would infer followup and drop out of the nav.
followup: false
name: How can this website improve?
description: >
This site is built on the same question architecture you're using
@@ -13,13 +14,17 @@ alternatives:
a page under questions/, or aggregates.yaml — verbatim, so it
has to be complete, valid YAML for that file, not a diff.
Nothing outside those files is in scope.
action: /proposed
action: proposed
# Announced, not yet open: the automation that turns a proposal
# into a pull request is still being built.
disabled: true
consequence: [Propose it]
record_as: development_proposals
encouragements:
- Every proposal gets read by a person before anything moves.
features:
- name: The reference
icon: lucide:book-open
description: >
Every page this site serves, verbatim — check what exists
before proposing against it.
@@ -31,6 +36,7 @@ alternatives:
jq: >
{name: .full_name, description: .description, url: .html_url}
- name: Sign it
icon: lucide:pen-line
description: >
A proposal is a stance — it carries a name, and an address
if you want the outcome to reach you.
@@ -42,6 +48,7 @@ alternatives:
type: email
optional: true
- name: The change
icon: lucide:git-branch
description: >
Pick the page you're changing and its current content loads
below, ready to edit in place — or name a new file's path if
@@ -56,9 +63,12 @@ alternatives:
public: true
source:
kind: url
url: https://project.uhhm.no/api/v1/repos/uhhm/questions/contents/questions?ref=main
# The whole tree, not one directory: since the tree became
# the router, pages live in subdirectories too.
url: https://project.uhhm.no/api/v1/repos/uhhm/questions/git/trees/main?recursive=true
jq: >
.[] | select(.type == "file") | {path: .path, name: .name}
.tree[] | select(.type == "blob" and (.path | startswith("questions/")) and (.path | endswith(".yaml")))
| {path: .path, name: .path}
- name: new_path
label: Or a new file's path
placeholder: questions/example.yaml
@@ -1,7 +1,7 @@
id: /proposed
route: ^/proposed
followup: true
name: Proposal received
# Reached from the proposal form only - and provably so: the visitor
# must hold a ?chain= lineage ending at an answer to proposal.yaml.
requires_chain: proposal
name: What happens to your proposal?
description: >
It's in the queue. An owner reads every proposal in full — approved
ones open a real pull request, and the same checks every human
+10 -5
View File
@@ -1,4 +1,3 @@
id: /
name: What are you here for?
description: >
Strategies that address problems — applied in software.
@@ -17,6 +16,7 @@ alternatives:
- We read every word before breaking ground.
features:
- name: What we've built
icon: lucide:layers
description: A few things already out in the world.
resource:
source:
@@ -27,15 +27,15 @@ alternatives:
# repos can point at something the public can actually
# open; a private repo with no website gets no link at all
# (the card renders an unlinked heading) instead of a 404.
# Gitea calls the star count stars_count, not GitHub's
# stargazers_count.
# TODO: star counts (Gitea's .stars_count) are left out until
# they're worth showing - add `stars: .stars_count` back here.
jq: >
.[] | {name: .name, description: .description,
stars: .stars_count,
url: (if .website != null and .website != "" then .website
elif .private then null
else .html_url end)}
- name: Your brief
icon: lucide:file-text
description: >
A few lines are enough to begin a conversation — we need a
name and an address because the reply is personal, and the
@@ -67,7 +67,10 @@ alternatives:
- Consider yourself posted.
features:
- name: Where to send it
description: ""
icon: lucide:mail
description: >
Notes go to a person, not a list — a name and an address is
all it takes.
requirements:
- name: name
label: Name
@@ -87,6 +90,7 @@ alternatives:
- Knock — we actually answer.
features:
- name: A way to reach you
icon: lucide:at-sign
description: The reply is personal — it needs a name and an address.
requirements:
- name: name
@@ -95,6 +99,7 @@ alternatives:
label: Email
type: email
- name: Your angle
icon: lucide:compass
description: >
We build around what each person actually brings — say what
that is, in your own words.
-2
View File
@@ -1,5 +1,3 @@
id: /project
route: ^/project
followup: true
name: So, what happens now?
description: >
+20
View File
@@ -0,0 +1,20 @@
# Dynamic page: /review/<chain-hash> shows one development proposal,
# addressed by its record key. The {record} placeholder receives the
# URL segment; the section's qualifies (plus the resource's own
# requires_group) keeps it owner-only.
name: Which proposal is this?
description: >
A single development proposal, addressed by its record key — link or
paste a chain hash from the desk.
alternatives:
- name: Record
description: ""
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: development_proposals
key: "{record}"
requires_group: owners
+3
View File
@@ -0,0 +1,3 @@
# Everything under /review is the owners' area - one gate for the
# whole directory instead of a qualifies flag per file.
qualifies: owners
@@ -1,7 +1,4 @@
id: /review
route: ^/review
name: The review desk
qualifies: owners
name: What came in?
description: >
Sign in as an organizational owner to see what's come in.
alternatives:
@@ -58,13 +55,17 @@ alternatives:
label: Churn
- name: Record a prospect
description: ""
description: >
A relationship worth tracking before it's a project — enter it
here and walk it through its states from the desk.
action: /review
consequence: [Record]
record_as: organizations
encouragements:
- Written down is halfway to followed up.
features:
- name: ""
description: ""
description: The organization and one person there to reach.
requirements:
- name: name
label: Organization
@@ -115,3 +116,26 @@ alternatives:
- name: body
label: Body
type: prosekit
- 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
-2
View File
@@ -1,5 +1,3 @@
id: /subscribed
route: ^/subscribed
followup: true
name: What should you expect?
description: >
+6
View File
@@ -0,0 +1,6 @@
# Instance branding - title and wordmark left at portal's defaults
# (SITE_NAME, /wordmark.svg); the landing hero is this repo's own
# "YES - Rasterized Lines" module, served at /site/hero.js.
hero:
kind: module
module: hero.js