25 Commits
Author SHA1 Message Date
Bendik LynghaugandClaude Opus 5 8d1b473c33 Portal v0.6.23 - the profile's PayloadVersion typing fix
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 Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:31:20 +02:00
Bendik LynghaugandClaude Opus 5 9465bc6a2f Offer an iOS configuration profile
Deploy instance / deploy (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Lint and reload / lint (push) Successful in 3s
Portal v0.6.22 renders /profile.mobileconfig from Stalwart's PACC
document: mail, calendars and contacts in one install, with the hosts
and ports stated rather than probed for. Nothing about the mail server
is written down here - pacc points at the description that already
exists, so this file has nothing to keep in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:23:28 +02:00
Bendik LynghaugandClaude Opus 5 82744011ed Serve PACC on the mail domain
Deploy instance / deploy (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
Lint and reload / lint (push) Successful in 1s
Stalwart implements PACC - /.well-known/user-agent-configuration.json,
one JSON document advertising JMAP, IMAP, SMTP, CalDAV, CardDAV, WebDAV,
ManageSieve and how to authenticate. Unlike autoconfig and Autodiscover
it is defined to be fetched from the mail domain itself rather than from
a guessed subdomain, so the apex is exactly where it belongs, and it was
only reachable at mail.<domain>. /.well-known/mail-v1.xml goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:43:16 +02:00
Bendik LynghaugandClaude Opus 5 aeef98389a Serve the DAV collections the discovery redirects point at
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
/.well-known/caldav answered 307 to https://<domain>/dav/cal, and that
path was portal's catch-all - so a client that did the discovery
correctly followed it into an HTML page. Stalwart builds the Location
from the forwarded host rather than the Host this route rewrites, so
rather than fight that, /dav/* is served here too and the client stays
on one origin for the whole exchange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:38:41 +02:00
Bendik LynghaugandClaude Opus 5 07d3dc36d3 Publish files at the site root, and find CalDAV from the bare domain
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Portal v0.6.21 serves this repo's .well-known/ at the root of the site,
so security.txt is a file in this repo rather than something only a
reverse proxy could place. A path that is not here answers 404 now,
instead of the app shell answering 200 with HTML.

Caddy also hands /.well-known/caldav and /.well-known/carddav to
Stalwart, which answers each with a 307 to the collection. RFC 6764 has
a client look for these on the domain its address is in - and iOS uses
them, which matters because Apple Mail ignores both autoconfig and
Autodiscover for IMAP accounts and discovers nothing else by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:35:48 +02:00
Bendik LynghaugandClaude Opus 5 30f9025d40 Serve mail autoconfiguration on the bare domain
Lint and reload / reload (push) Successful in 0s
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 1s
Thunderbird asks <domain>/.well-known/autoconfig, Outlook POSTs to
<domain>/Autodiscover, and both fell into portal's catch-all, which
answers 200 with an HTML page - so a client got markup where it expected
XML and went back to guessing.

Both prefixes now go to Stalwart, which already generates the answers,
templated on the address asked about. No static copy to drift out of
date and no hostname of their own to certify. Host is rewritten to the
name Stalwart has always answered for, and all three spellings of
/Autodiscover are matched because Outlook is not consistent about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 09:29:06 +02:00
Bendik LynghaugandClaude Opus 5 15704959ad Take /einer off uhhm.no
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Not where this belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 08:03:47 +02:00
Bendik LynghaugandClaude Opus 5 7de9bfe767 Say where the knowledge about juniper wood stops
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
/einer is four frontiers and nothing else: the gap between berry and
heartwood chemistry, the gap between inhibition on agar and durability
in wood, the delivery problem that a bio-based impregnation chemistry
might already solve, and whether extractive-driven durability can be
moved between species at all.

Context cards only - no form, no bucket, nothing asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 19:32:10 +02:00
Bendik LynghaugandClaude Opus 5 2efb868f16 Hide the session nav signed in too, not only signed out
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
:has(a[href^="/auth/login"]) only ever matched the signed-out nav, so
an owner still had a name and a sign-out beside the wordmark. The
header carries the wordmark and nothing else now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 19:27:04 +02:00
Bendik LynghaugandClaude Opus 5 d350732913 Take /einer down
Lint and reload / lint (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
The page led with what I lack rather than with what the question is.
Coming back when it is about the knowledge, not the asker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 17:37:37 +02:00
Bendik LynghaugandClaude Opus 5 caba12df09 Ask the juniper question in the open
Lint and reload / lint (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
/einer states what is known about juniper heartwood extractives — the
sesquiterpenes that carry the scent are the ones that inhibit decay
fungi — and what is not, then takes the two things the project needs:
people following along, and people with wood, equipment or lab time.

Both land in one bucket, worked from /review, because at this stage
they are the same conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 17:28:34 +02:00
Bendik LynghaugandClaude Opus 5 aa9987fb2e Hide the sign-in link beside the wordmark
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
uhhm.no is read and answered without an account, so the invitation to
sign in has no audience. Portal's header nav keeps it next to the
wordmark on every page; a skin hides it, as on redoal.com.

The rule only takes the link in: signed in, the nav still carries the
name and the way out, and the gate on /review still offers its own way
in. First custom.css here, wired through site.yaml's stylesheet key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 13:17:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 510b4b4ff5 The invite mail in English, like the rest of the site
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 22:02:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9c5e66c7c0 The desk says the invite mail goes out by itself
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 22:00:31 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 3b8822cd78 The invite form has its button: action back to the desk
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:38:23 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db6ead16c3 The review desk lists the invites sent, with their links
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:29:24 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9df0368175 Lint with portal lint: iris is no longer released
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:26:19 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 8788069ae2 Portal v0.6.15: invites with their own mail, for invites to a conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:25:59 +02:00
Bendik Aagaard Lynghaug 5682588a50 Merge branch 'invite-to-conversation' 2026-10-01 21:25:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a6930233a5 Invite to a conversation: a passkey with us and a time in the lobby on chat.uhhm.no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 18:09:13 +02:00
Bendik Aagaard Lynghaug e00b9cfa0b Take portal v0.5.2 (the rate limiter needs connect info)
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-09-28 18:13:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 4381078b86 Take portal v0.5.1
Deploy instance / deploy (push) Failing after 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Attachments both ways, rate limiting, sessions that survive a deploy,
and an upload route that asks the policy like every other way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 17:53:46 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 ecca063b5c Take portal v0.4.0
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Four releases on from v0.3.36: maps and `place:`, a reply to a case
mail becoming a note on its record, and the access policy's newer
rules. iris moves with it, since it is the lint for exactly this
portal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:27:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b9774c90f6 README: the mail stream is called mail
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 1s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:19:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9917b07312 README: mail is content - mail: on a state, mail: in site.yaml, gdo
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:50:22 +02:00
33 changed files with 733 additions and 1048 deletions
+55 -2
View File
@@ -18,7 +18,7 @@ on:
- .gitea/workflows/deploy.yml
env:
PORTAL_RELEASE: v0.4.0
PORTAL_RELEASE: v0.6.23
INSTANCE: uhhm-portal
jobs:
@@ -114,7 +114,60 @@ jobs:
}
{$DOMAIN} {
reverse_proxy host.docker.internal:3010
# Mail clients look for their configuration on the bare domain -
# Thunderbird at /.well-known/autoconfig, Outlook by POSTing to
# /Autodiscover. Stalwart already generates both, templated on the
# address asked about, so these two paths go to it instead of being
# copied into this repo: one source of truth, and no second
# hostname to certify. Host is rewritten to the name Stalwart
# answers for; it never sees a request for the bare domain
# otherwise. (MTA-STS cannot be served here - RFC 8461 requires
# mta-sts.<domain> - so it keeps its own record and route.)
# PACC, the standard that replaces guessing at hostnames: one JSON
# document on the mail domain itself advertising every protocol,
# which is why it belongs here and not on a subdomain. Stalwart
# generates it, and /.well-known/mail-v1.xml beside it.
@pacc path /.well-known/user-agent-configuration.json /.well-known/mail-v1.xml
handle @pacc {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
handle /.well-known/autoconfig/* {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
# CalDAV and CardDAV discovery (RFC 6764): a client looks for
# these on the domain its address is in, and Stalwart answers
# each with a 307 to the collection. iOS uses these even though
# Apple Mail ignores autoconfig and Autodiscover entirely.
# The collections the two redirects above point at. Stalwart builds
# those Location headers from the forwarded host, not from the Host
# this route rewrites, so they land on the bare domain - which means
# the collections have to be reachable here too, or a client follows
# a 307 into portal's catch-all. Keeping them on one host is the
# better answer anyway: the client never changes origin.
@dav path /.well-known/caldav /.well-known/carddav /dav/*
handle @dav {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
@autodiscover path /Autodiscover/* /autodiscover/* /AutoDiscover/*
handle @autodiscover {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
handle {
reverse_proxy host.docker.internal:3010
}
log {
output file /var/log/caddy/www.log
}
+7 -7
View File
@@ -15,18 +15,18 @@ jobs:
steps:
- uses: actions/checkout@v4
# iris (uhhm/iris) is the lint: it reads content with the portal
# release it is pinned to, so IRIS_RELEASE should match the portal
# this site runs (PORTAL_RELEASE in deploy.yml).
# The lint is portal itself (`portal lint`, since portal 0.6.0;
# iris is no longer released), at the version this site runs
# (PORTAL_RELEASE in deploy.yml).
- name: Lint questions
env:
IRIS_RELEASE: v0.4.0
PORTAL_RELEASE: v0.6.23
run: |
# The checkout is on a noexec mount for this runner; the cargo
# target dir is where it may run what it fetched.
bin="/var/local/cargo-target/iris-$IRIS_RELEASE"
[ -x "$bin" ] || { curl -sfL "${{ github.server_url }}/uhhm/iris/releases/download/$IRIS_RELEASE/iris" -o "$bin" && chmod +x "$bin"; }
"$bin" check --path questions
bin="/var/local/cargo-target/portal-lint-$PORTAL_RELEASE"
[ -x "$bin" ] || { curl -sfL "${{ github.server_url }}/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o "$bin" && chmod +x "$bin"; }
"$bin" lint --path questions
reload:
runs-on: bare
+6
View File
@@ -0,0 +1,6 @@
# How to report a security problem with uhhm.no, served from this repo's
# .well-known/ by portal's static root overlay.
Contact: mailto:bl@uhhm.no
Expires: 2027-10-07T00:00:00.000Z
Preferred-Languages: no, en
Canonical: https://uhhm.no/.well-known/security.txt
+31
View File
@@ -289,6 +289,37 @@ 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.
**Mail is content** (portal 0.3.43, delivered by
[gdo](https://project.uhhm.no/uhhm/gdo)). A state in `aggregates.yaml`
may carry the mail its arrival sends:
```yaml
states:
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read every one. Withdraw it here: {site}/decide/brief?chain={chain}&email={email}" } }
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "..." } }
```
`to` is `submitter` (the record's `email` field; lint then requires
every form recording into that bucket to collect a required `email`)
or one fixed address. Subject and body are templates over the
record's fields, plus `{site}` (the instance's public URL), `{chain}`
(the record id), `{email}`, `{bucket}`, `{state}`; the body is
markdown, sent as plain text with an HTML alternative. An invite
(`invite:` on an alternative) mails its one-time link built in, in the
site's `lang`. None of it is sent unless `site.yaml` says who it comes
from:
```yaml
mail:
from: UHHM <portal@uhhm.no>
reply_to: bl@uhhm.no # optional
invite: { subject: "...", body: "..." } # optional rewording; {name} {username} {email} {group} {link} {site} {site_name} {expires}
```
Portal renders each mail the moment the record enters the state and
publishes it on `portal.mail.send` in the `mail` JetStream stream;
gdo, one per host, hands it to the host's SMTP. A transition that
fires twice sends once.
## Routing: the tree is the router
The `questions/` directory tree is the URL tree — `index.yaml` names
+66 -153
View File
@@ -1,155 +1,68 @@
# The full state graph for every bucket that wants event-sourced,
# CAS-protected transitions - see portal's src/aggregates/mod.rs. A
# bucket with no entry here still works (plain KV mutate-in-place, no
# event log). `bucket` doubles as both the KV bucket name and the
# JetStream event-subject segment (events.<bucket>.<id>) - one name,
# not two.
#
# Pages (review.yaml, subscribed.yaml, index.yaml) reference these
# bucket names and `to:` state names by string; they never re-declare
# the graph itself. Add a new aggregate here alone - no portal code
# change needed.
aggregates:
- bucket: applicants
initial: open
states:
active:
event: onboarded
declined:
event: declined
departed:
event: departed
invited:
event: invited
open:
event: applied
transitions:
active:
- departed
invited:
- active
- declined
open:
- invited
- declined
- bucket: custody_requests
initial: open
states:
declined:
event: declined
mail:
body: We've read your message and decided this isn't something we can take on as custodians. If you think we've missed something, write back and tell us.
subject: On your message to the custodians
to: submitter
in_dialogue:
event: in_dialogue
open:
event: received
mail:
body: Your message reached the custodians of the first node. We read everything that comes in - expect a real reply, not an autoresponder.
subject: We have your message
to: submitter
resolved:
event: resolved
mail:
body: This is resolved. Here's what we found and what we did about it - write back if it isn't settled from where you sit.
subject: Your case is resolved
to: submitter
transitions:
in_dialogue:
- resolved
- declined
open:
- in_dialogue
- declined
- bucket: development_proposals
initial: open
states:
approved:
event: approved
open:
event: proposed
rejected:
event: rejected
transitions:
open:
- approved
- rejected
- bucket: organizations
initial: prospect
states:
client:
event: became_client
past_client:
event: churned
prospect:
event: identified
transitions:
client:
- past_client
prospect:
- client
- past_client
- bucket: projects
initial: open
states:
accepted:
event: accepted
mail:
body: We want to take this on. Next we'll be in touch to talk through scope, timing and how we'll work together.
subject: We'd like to take this on
to: submitter
declined:
event: declined
mail:
body: We've read your brief and this isn't one we're taking on. It isn't a judgment on the problem - just not a fit for us right now.
subject: On your brief
to: submitter
open:
event: submitted
mail:
body: |-
Your brief is in front of us. We read the whole thing before we reply, so it may take a few days - you'll hear from us either way.
# One continuous graph per person, applicant through employee - the
# bucket name stays "applicants" even as a record progresses past the
# applying stage (a deliberate trade: one chain of events per person,
# no identity-linking between separate buckets).
- bucket: applicants
initial: open
states:
open: { event: applied }
invited: { event: invited }
declined: { event: declined }
active: { event: onboarded }
departed: { event: departed }
transitions:
open: [invited, declined]
invited: [active, declined]
active: [departed]
What you can do next: {site}/decide/commission?chain={chain}&email={email}
subject: We have your brief
to: submitter
transitions:
open:
- accepted
- declined
- bucket: subscribers
initial: open
states:
open:
event: subscribed
unsubscribed:
event: unsubscribed
transitions:
open:
- unsubscribed
- attended_by: |
approved -> previewed: the n8n workflow that builds the draft site from an approved intake (docker, rauthy, portal content) and sends the preview link. Until it exists, owners run portal-trainer by hand and press the button.
bucket: trials
initial: open
states:
approved:
event: approved
mail:
body: Your draft is approved and on its way. We built it from your own words - have a look when it's ready and tell us what to change.
subject: Your draft is approved
to: submitter
declined:
event: declined
mail:
body: We've decided not to build this draft. If the situation changes, get back in touch and we'll take another look.
subject: On your draft
to: submitter
open:
event: received
mail:
body: We have what you sent. A person will read it and build your draft, or tell you why not - you'll hear from us either way.
subject: We're looking at your draft
to: submitter
previewed:
event: previewed
mail:
body: Your draft is ready to look at. It's built from your own words - the link below takes you straight to it.
subject: Your draft is ready to view
to: submitter
transitions:
approved:
- previewed
- declined
open:
- approved
- declined
- bucket: subscribers
initial: open
states:
open: { event: subscribed }
unsubscribed: { event: unsubscribed }
transitions:
open: [unsubscribed]
- bucket: projects
initial: open
states:
open: { event: submitted }
accepted: { event: accepted }
declined: { event: declined }
transitions:
open: [accepted, declined]
- bucket: organizations
initial: prospect
states:
prospect: { event: identified }
client: { event: became_client }
past_client: { event: churned }
transitions:
prospect: [client, past_client]
client: [past_client]
# Proposals to change this very repo - the question architecture
# dogfooding its own development. Approval hands off to an n8n
# automation that opens a PR against this repo (see the infrastructure
# repo's development-commit workflow); the lint CI gates the merge.
- bucket: development_proposals
initial: open
states:
open: { event: proposed }
approved: { event: approved }
rejected: { event: rejected }
transitions:
open: [approved, rejected]
+13
View File
@@ -0,0 +1,13 @@
/* uhhm - the skin over portal's own stylesheet
(site.yaml `stylesheet`, loaded after main.css, so these rules win). */
/* Portal puts the way in beside the wordmark on every page of a site
that has sign-in at all, and there is no setting to turn it off:
`sign_in` follows whether OIDC is enabled on the instance. uhhm.no is
read and answered without an account, so the whole nav goes - signed
out, where it reads "Sign in", and signed in, where it reads a name
and "Sign out". Whoever edits the site goes to /auth/login and
/auth/logout directly. */
.session {
display: none;
}
-204
View File
@@ -1,204 +0,0 @@
needs:
- done_when:
- accepted
- declined
enters: /
handled_by: owners
id: commission
lands_in: projects
max_fields: 4
max_steps: 1
stage_words:
accepted: taken on
declined: not a fit for us
open: received
stages:
- open
- accepted
- declined
success:
- accepted
via: 'Have us build it: send the brief'
wants: a considered reply to a brief, from people who read the whole thing
who: an organization with a problem it wants software to solve
- done_when:
- previewed
- declined
enters: /
handled_by: owners
id: trial
lands_in: trials
max_fields: 6
max_steps: 1
stage_words:
approved: approved for a draft
declined: not a fit for us
open: received
previewed: a preview is ready
stages:
- open
- approved
- previewed
- declined
success:
- previewed
via: See your own business as a site, before you buy anything
wants: a first draft of a site for their own roles, built from their own words and approved by a person first
who: someone who runs a business where requests, approvals and handovers get lost, and wants to see it as a site before committing to anything
- done_when:
- departed
- declined
enters: /
handled_by: owners
id: collaborate
lands_in: applicants
max_fields: 3
max_steps: 1
stage_words:
active: working with us
declined: not a fit for us
departed: moved on
invited: invited in
open: received
stages:
- open
- invited
- active
- departed
- declined
success:
- departed
via: Work with us
wants: to be read as a person, and invited in if there is a fit
who: someone who wants to work with us on what we build
- done_when:
- unsubscribed
enters: /
handled_by: owners
id: subscribe
lands_in: subscribers
max_fields: 2
max_steps: 1
stage_words:
open: on the list
unsubscribed: off the list
stages:
- open
- unsubscribed
success: []
via: Hear from us now and then
wants: a note now and then, and an easy way out
who: someone who wants to hear from us when there is something to say
- done_when:
- approved
- rejected
enters: /
handled_by: owners
id: propose
lands_in: development_proposals
max_fields: 3
max_steps: 1
stage_words:
approved: approved
open: received
rejected: turned down
stages:
- open
- approved
- rejected
success:
- approved
via: Suggest a change to this website
wants: the change read by a person and, if approved, made
who: a visitor who sees how a page on this site should change
- done_when:
- resolved
- declined
enters: /
handled_by: custodians
id: custody
lands_in: custody_requests
max_fields: 4
max_steps: 1
stage_words:
declined: closed without action
in_dialogue: in dialogue with a custodian
open: received
resolved: resolved
stages:
- open
- in_dialogue
- resolved
- declined
success:
- resolved
via: 'You are on the redoal network: a node, an app, or a recording that concerns you'
wants: an answer from a custodian, and action where the network keeps something it should not
who: someone who runs, builds on, or is affected by the redoal network and needs the people who keep its first node
- done_when:
- past_client
enters: /owners/prospect
enters_as: owners
handled_by: owners
id: prospect
lands_in: organizations
max_fields: 2
max_steps: 1
stage_words:
client: a client
past_client: a past client
prospect: a prospect
stages:
- prospect
- client
- past_client
success: []
via: Keep an organization on your radar
wants: it written down and walked through its states from the desk
who: an owner who has met an organization worth following up
personas:
- id: architecture-firm
need: commission
text: We run a small architecture firm and our project files are scattered across three cloud drives. We want to pay someone to set up proper storage and backups we control ourselves.
- id: climbing-gym-founder
need: commission
text: I have an idea for a booking app for climbing gyms and I'm looking for a team that can build the first version with me.
- id: municipality-buyer
need: commission
text: Our municipality needs an internal tool for tracking building permits. We are looking for a vendor who can scope it and deliver it.
- id: exhibition-visitor
need: subscribe
text: I saw your drawing network at an exhibition and liked it. I don't want to get involved, I'd just like to know when there is news.
- id: tech-journalist
need: subscribe
text: I write about Nordic technology. Keep me posted on what you release so I can cover it.
- id: rust-developer
need: collaborate
text: I'm a Rust developer between jobs and I like that you build in the open. I would love to contribute, and maybe join you.
- id: interaction-designer
need: collaborate
text: I'm an interaction designer. I want to work with you on the drawing interface.
- id: student-intern
need: collaborate
text: I'm a computer science student looking for a summer internship doing infrastructure work.
- id: plumber-owner
need: trial
text: I run a plumbing company with four vans. Jobs come in by phone and text and some get lost. I'd like to see how this would look for us before I spend any money.
- id: dance-school
need: trial
text: We are a dance school. Enrolments, cancellations and invoices all go through one email inbox that only I understand. Can you show me a version made for us?
- id: node-runner
need: custody
text: I run a small server in Berlin and want to host a redoal node that peers with yours. Who do I talk to?
- id: recorded-name
need: custody
text: Someone left a recording on redoal that says my full name and where I live. I want it gone, and I want to know who is responsible for that network.
- id: app-builder
need: custody
text: I'm building a music app and would like to use redoal's gesture addresses in it. Is that possible, and who decides?
- id: wording-fix
need: propose
text: The subscribe page should say how often you actually write. I'd like to suggest that exact change to the page.
- id: rewrite
need: propose
text: One page on this site reads wrong to me and I know exactly how I'd rewrite it. Where do I send the new text?
+25
View File
@@ -0,0 +1,25 @@
followup: true
name: What happens now that you've applied?
description: >
We're not filling a seat — we're looking for exactly what you bring
that we don't already have. We read every one of these ourselves, in
full, and write back.
alternatives:
- name: Thanks — it's in front of us
description: ""
features:
- name: We need your specific angle
description: >
Not a generic fit — the particular thing you're good at, the
problem you'd actually notice, the take nobody else here
has. That's what we're reading for.
- name: Work happens through dialogue, not tickets
description: >
What you bring shapes what gets built, in an ongoing back
-and-forth — not a queue of pre-decided tasks handed down to
execute.
- name: You receive a personal invitation
description: >
A match earns you a real key — access, a measure of
responsibility, and eventually, the standing to invite
someone else in.
-1
View File
@@ -1 +0,0 @@
qualifies: custodians
-65
View File
@@ -1,65 +0,0 @@
alternatives:
- action: /custodians
consequence:
- Confirm
description: Cases from people who use, build on, or answer for something the redoal network touches, and need a custodian's word on it.
features:
- description: ''
name: ''
resource:
requires_group: custodians
source:
bucket: custody_requests
kind: kv
transitions:
- from: in_dialogue
label: Mark resolved
to: resolved
- from: in_dialogue
label: Close, no action
to: declined
- from: open
label: Open dialogue
to: in_dialogue
- from: open
label: Decline case
to: declined
name: Custody cases waiting on you
- action: /custodians
consequence:
- Invite custodian
description: Give someone an account and a one-time link to join as a custodian.
features:
- description: ''
name: Invite a custodian
requirements:
- label: Name
name: name
- label: Email
name: email
type: email
invite:
group: custodians
name: Bring in another custodian
- action: /custodians
consequence:
- Confirm
description: Custodians invited but not yet signed in.
features:
- description: ''
name: ''
resource:
requires_group: custodians
source:
bucket: portal_invites
kind: kv
transitions:
- from: open
label: Signed in
to: done
name: Invited custodians
description: 'The desk for custodians: open custody cases, and the people invited to answer them.'
name: What is waiting on the custodians?
responsible:
contact: bl@uhhm.no
name: Bendik Aagaard Lynghaug
-9
View File
@@ -1,9 +0,0 @@
alternatives:
- description: ''
name: Declined
self_transition:
bucket: projects
label: Declined
to: declined
description: ''
name: What would you like to do?
-9
View File
@@ -1,9 +0,0 @@
alternatives:
- description: ''
name: Resolved
self_transition:
bucket: custody_requests
label: Resolved
to: resolved
description: ''
name: What would you like to do?
-9
View File
@@ -1,9 +0,0 @@
alternatives:
- description: You'll stop hearing from us. Nothing else changes.
name: Stop getting notes from us
self_transition:
bucket: subscribers
label: Unsubscribe
to: unsubscribed
description: You're on our list. Change your mind whenever you like.
name: Do you want to keep hearing from us?
+24
View File
@@ -0,0 +1,24 @@
name: What's been proposed?
qualifies: owners
description: >
Proposed changes to the questions repo, awaiting a decision.
Approval hands off to the automation that opens the pull request —
lint gates the merge, not this page.
alternatives:
- name: Proposals
description: ""
action: /develop
consequence: [Confirm]
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: development_proposals
requires_group: owners
transitions:
- to: approved
label: Approve
- to: rejected
label: Reject
+90
View File
@@ -0,0 +1,90 @@
# 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
right now — and changes to it are proposed through it. Tell us what
should change and why; an owner reads every proposal, and approved
ones become real commits.
alternatives:
- name: The change you propose
description: >
The proposed content replaces (or creates) one content file —
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
# 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.
resource:
public: true
source:
kind: url
url: https://project.uhhm.no/api/v1/repos/uhhm/questions
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.
requirements:
- name: submitted_by
label: Name (or what to call you)
- name: email
label: Email
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
the proposal creates one.
requirements:
- name: target_path
label: The file to change
type: select
optional: true
id_field: path
resource:
public: true
source:
kind: url
# 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: >
.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
optional: true
- name: rationale
label: The rationale
type: textarea
- name: proposed_yaml
label: Proposed file content (complete YAML)
type: textarea
bind:
field: target_path
param: path
resource:
public: true
source:
kind: url
url: https://project.uhhm.no/api/v1/repos/uhhm/questions/contents/{path}?ref=main
jq: ".content | @base64d"
+18
View File
@@ -0,0 +1,18 @@
# 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
commit passes gate the merge.
alternatives:
- name: The path from here
description: ""
features:
- name: A person decides
description: Nothing merges on autopilot — approval is a deliberate act.
- name: The linter has no favorites
description: >
Approved or not, your YAML faces the same validation as
everything else in the repo.
+111 -268
View File
@@ -1,270 +1,113 @@
alternatives:
- description: Two things, both built here and both running.
features:
- description: 'UHHM builds and carries the redoal network: the gesture math, the relay, the first node, and the portal this page runs on. Run a node, build on it, or have a recording removed, and you say when it is resolved.'
name: A network that answers to you
- description: 'Infrastructure, tools and products for organizations that can say what problem they have. Bring the brief, or first see your own way of working as a working site, before you pay for anything. The brief stays yours: you can withdraw it yourself.'
icon: lucide:layers
name: A considered reply to your brief, and software that fits the argument
name: What UHHM does
- description: Nothing to fill in here. What is already out in the world, and the network as it runs right now.
features:
- description: A few things already out in the world.
icon: lucide:hammer
name: What we have built
resource:
jq: |
.[] | {name: .name, description: .description, url: (if .website != null and .website != "" then .website elif .private then null else .html_url end)}
public: true
source:
kind: gitea_starred
username: bl
- description: Straight from the relay, the first node of the network.
icon: lucide:activity
name: The redoal network, live
resource:
empty: The first voice has not echoed yet.
jq: |
{ "name": (.voices_echoed | tostring),
"description": "voices echoed so far" }
public: true
source:
kind: url
url: https://relay.redoal.com/pulse
name: Have a look around first
- action: /received/commission
consequence:
- Send the brief
description: You know the problem and want it solved, whether it is infrastructure, a tool, or the first version of a product. Write it up for people who read the whole thing.
encouragements:
- Someone here reads every brief in full before writing back.
features:
- description: We read your brief and either take it on or tell you honestly if it's not a fit.
icon: lucide:route
name: What happens next
- description: One of the owners reads it; write to bl@uhhm.no if you'd rather reach us directly.
icon: lucide:user-check
name: Who reads this
- description: The more of the problem you put in, the better the reply.
icon: lucide:briefcase
name: Tell us the problem
requirements:
- label: Your name
name: name
placeholder: Sara Nilsen
type: text
- label: Your email
name: email
placeholder: sara@company.com
type: email
- label: The problem you want solved
name: project
placeholder: Our drivers log hours on paper and it takes a day to get them into payroll.
type: textarea
- label: Your organization
name: organization
placeholder: Nilsen Transport AS
type: text
- label: Who we should talk to
name: org_contact
optional: true
placeholder: Sara Nilsen, ops manager
type: text
name: 'Have us build it: send the brief'
record_as: projects
- action: /received/trial
consequence:
- Start the draft
description: Describe how requests, approvals and handovers move through your business today. We approve it by hand and build a working draft of a site for your own roles, for you to click through.
encouragements:
- A person looks at what you send before anything gets built.
features:
- description: We look at what you sent and, if it's a fit, build a draft from your own roles and words for you to see before anything moves further.
icon: lucide:route
name: What happens next
- description: An owner looks at what you send; write to bl@uhhm.no if you'd rather reach us directly.
icon: lucide:user-check
name: Who reads this
- description: Answer in your own words - there's no wrong way to describe your business.
icon: lucide:layers
name: Describe your business
requirements:
- label: Your business
name: business
placeholder: Nilsen Transport AS
type: text
- label: Your email
name: email
placeholder: sara@company.com
type: email
- label: What you do
name: what_we_do
placeholder: We move freight for local businesses and handle the paperwork for them.
type: textarea
- label: Who comes to you
name: who_comes_to_us
placeholder: Local businesses that need something moved on short notice.
type: textarea
- label: What you keep track of
name: what_we_keep_track_of
placeholder: Bookings, driver hours, and which loads are still waiting on a signature.
type: textarea
- label: Who's involved
name: who_is_involved
placeholder: Drivers, dispatchers, and the customers waiting on their delivery.
type: textarea
- label: What slows you down
name: what_slows_us_down
optional: true
placeholder: Approvals sit in someone's inbox for days before anyone acts on them.
type: textarea
name: See your own business as a site, before you buy anything
record_as: trials
- action: /received/collaborate
consequence:
- Get in touch
description: 'A developer, a designer, a student: say what you would want to build with us, and we read it as a person and invite you in if there is a fit.'
encouragements:
- A person reads this, not a form filter — worth the ten minutes.
features:
- description: We read what you send and get back to you. If there's a fit, we invite you in and you start working with us — and if things ever change, that's fine too.
icon: lucide:route
name: What happens next
- description: One of the owners reads this directly — for anything urgent, write to bl@uhhm.no.
icon: lucide:user-check
name: Who reads this
- description: Tell us who you are and what kind of work you're after.
icon: lucide:handshake
name: Say hello
requirements:
- label: Name
name: name
placeholder: Your name
type: text
- label: Email
name: email
placeholder: you@example.com
type: email
- label: What kind of work
name: interest
placeholder: Backend work, or something design-heavy
type: textarea
- label: Tell us more
name: note
optional: true
placeholder: A bit about what you'd want to work on, or a link to your work
type: textarea
name: Work with us
record_as: applicants
- action: /received/subscribe
consequence:
- Sign up
description: You'd rather hear from us now and then than check back on your own.
encouragements:
- One click unsubscribes you later, no questions asked.
features:
- description: You're on the list. We write when there's something worth saying, and you can leave the list whenever you like.
icon: lucide:bell
name: What happens next
- description: An owner sends these notes directly — write to bl@uhhm.no if you'd rather ask something first.
icon: lucide:user-check
name: Who reads this
- description: Just your email, or your name too if you'd like.
icon: lucide:mail
name: Stay in the loop
requirements:
- label: Name
name: name
placeholder: Your name
type: text
- label: Email
name: email
placeholder: you@example.com
type: email
name: Hear from us now and then
record_as: subscribers
- action: /received/propose
consequence:
- Send change
description: You have spotted a page on uhhm.no that should read differently, and you know roughly how.
encouragements:
- Even a rough version helps — we can tidy the wording ourselves.
features:
- description: Someone here reads your proposal and either makes the change or explains why not. Either way, you'll hear back.
icon: lucide:route
name: What happens next
- description: An owner reads every proposal before anything changes — reach us at bl@uhhm.no if it's time-sensitive.
icon: lucide:user-check
name: Who reads this
- description: Point us to the page and show us the change.
icon: lucide:pencil
name: Propose a change
requirements:
- label: Your name
name: submitted_by
placeholder: Your name
type: text
- label: Email
name: email
optional: true
placeholder: you@example.com
type: email
- label: Which page
name: page
optional: true
placeholder: questions/join/index.yaml
type: text
- label: Why this is better
name: rationale
placeholder: The current heading doesn't say what you actually get
type: textarea
- label: Your proposed text
name: proposed_yaml
placeholder: 'heading: "A clearer way to say this"'
type: textarea
name: Suggest a change to this website
record_as: development_proposals
- action: /received/custody
consequence:
- Send to a custodian
description: UHHM hosts the redoal network's first node and answers for what it keeps. Write here if you run a node, build on the network, or want something it holds about you looked at.
encouragements:
- A custodian reads every message that comes in here.
features:
- description: A custodian opens a dialogue with you. A node gets its peer, a builder gets an answer, and something the network should not keep gets removed. Either way you hear what was decided.
icon: lucide:route
name: What happens next
- description: A custodian reads it directly; if it's urgent, write to bl@uhhm.no in the meantime.
icon: lucide:user-check
name: Who reads this
- description: Say which of the four it is, and what you need, in a few lines.
icon: lucide:shield
name: Reach a custodian
requirements:
- label: Name
name: name
placeholder: Your name
type: text
- label: Email
name: email
placeholder: you@yourcompany.com
type: email
- label: What's going on
name: about
options:
- Running a node of my own
- Building on the network
- Something the network keeps about me
- Something else
type: select
- label: In short
name: what
placeholder: A relay node is misbehaving
type: textarea
name: 'You are on the redoal network: a node, an app, or a recording that concerns you'
record_as: custody_requests
description: UHHM builds software for organisations that can say what problem they have, and keeps the redoal network underneath.
name: What is UHHM, and what can you get here?
name: What are you here for?
description: >
Strategies that address problems — applied in software.
responsible:
contact: bl@uhhm.no
name: Bendik Aagaard Lynghaug
contact: bl@uhhm.no
alternatives:
- name: Good software begins with a clear argument
description: >
Infrastructure, tools, or a product — bring the problem, and we
read the whole brief before answering.
action: /project
consequence: [Tell us what you're after]
record_as: projects
encouragements:
- 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:
kind: gitea_starred
username: bl
public: true
# The link prefers the repo's Website setting so private
# 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.
# 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,
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
organization because software always lands somewhere.
requirements:
- name: name
label: Name
- name: email
label: Email
type: email
- name: project
label: The build
type: textarea
placeholder: Just the shape of it — we'll ask the right questions.
- name: organization
label: Organization
- name: org_contact
label: Their contact, if not you
optional: true
- name: Attention is earned
description: >
Considered notes on what we're building, and why — sent only when
there's something worth saying.
action: /subscribed
consequence: [Subscribe]
record_as: subscribers
encouragements:
- Consider yourself posted.
features:
- name: Where to send it
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
- name: email
label: Email
type: email
- name: Work happens through dialogue, not tickets
description: >
What you bring shapes what gets built. We onboard collaborators
the same deliberate way we build — by hand, through a real
conversation.
action: /applied
consequence: [Apply]
record_as: applicants
encouragements:
- 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
label: Name
- name: email
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.
requirements:
- name: interest
label: The work you're drawn to
type: textarea
- name: note
label: Anything else worth knowing
type: textarea
optional: true
-1
View File
@@ -1 +0,0 @@
qualifies: owners
-209
View File
@@ -1,209 +0,0 @@
alternatives:
- action: /owners
consequence:
- Confirm
description: People who've asked to build with us, waiting on a decision or already working alongside us.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: applicants
kind: kv
transitions:
- from: active
label: Mark departed
to: departed
- from: invited
label: Confirm active
to: active
- from: invited
label: Withdraw the invite
to: declined
- from: open
label: Send invite
to: invited
- from: open
label: Decline applicant
to: declined
name: People who want to build with us
- action: /owners
consequence:
- Confirm
description: Questions and issues raised with the people who keep the first node - custodians handle these, but you can see where each one stands.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: custody_requests
kind: kv
transitions: []
name: What's been raised with custodians
- action: /owners
consequence:
- Confirm
description: Changes visitors have proposed for pages on this site, waiting on a yes or no.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: development_proposals
kind: kv
transitions:
- from: open
label: Approve change
to: approved
- from: open
label: Reject change
to: rejected
name: Proposed changes to the site
- action: /owners
consequence:
- Confirm
description: Organizations you've met, tracked from first contact through to client and beyond.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: organizations
kind: kv
transitions:
- from: client
label: Mark past client
to: past_client
- from: prospect
label: Mark as client
to: client
- from: prospect
label: Archive as past client
to: past_client
name: Organizations you're tracking
- action: /owners
consequence:
- Confirm
description: Briefs sent in by organizations with a problem they want solved.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: projects
kind: kv
transitions:
- from: open
label: Accept brief
to: accepted
- from: open
label: Decline brief
to: declined
name: Briefs waiting on a reply
- action: /owners
consequence:
- Confirm
description: People who want to hear from you now and then.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: subscribers
kind: kv
transitions:
- from: open
label: Unsubscribe them
to: unsubscribed
name: Who's hearing from you
- action: /owners
consequence:
- Confirm
description: Businesses who want to see their own workflow as a working site before committing to anything.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: trials
kind: kv
transitions:
- from: approved
label: Mark previewed
to: previewed
- from: approved
label: Decline trial
to: declined
- from: open
label: Approve trial
to: approved
- from: open
label: Decline trial
to: declined
name: Trial sites in progress
- action: /owners/prospect
consequence:
- Add prospect
description: Write down an organization worth following up, and track it from first contact onward.
name: Track a new organization
- action: /owners
consequence:
- Send invite
description: Bring someone in to help keep the first node and answer for the network.
features:
- description: ''
name: Invite a custodian
requirements:
- label: Their name
name: name
- label: Their email
name: email
type: email
invite:
group: custodians
name: Bring in a custodian
- action: /owners
consequence:
- Send invite
description: Bring someone in to work this desk alongside you.
features:
- description: ''
name: Invite an owner
requirements:
- label: Their name
name: name
- label: Their email
name: email
type: email
invite:
group: owners
name: Bring in another owner
- action: /owners
consequence:
- Confirm
description: People you've invited in, and whether they've taken it up yet.
features:
- description: ''
name: ''
resource:
requires_group: owners
source:
bucket: portal_invites
kind: kv
transitions:
- from: open
label: Taken up
to: done
name: Invites you've sent
description: 'Everything waiting on you: briefs, trials, applicants, subscribers, and proposals, all in one place.'
name: What's waiting on me today?
responsible:
contact: bl@uhhm.no
name: Bendik Aagaard Lynghaug
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: An owner walks it from prospect to client, and on to past client, as the relationship moves.
name: It's on the desk now
- action: /owners
consequence:
- Back to site
description: ''
name: Back to site
description: Thanks — we've got your prospect written down, and it now moves through the desk with everything else we're tracking.
name: What happens to this now?
-39
View File
@@ -1,39 +0,0 @@
alternatives:
- action: /owners/prospect-sent
consequence:
- Add to prospects
description: One line per organization worth watching, moved through the stages as things develop.
encouragements:
- Takes a minute, and it's yours to update later.
features:
- description: It sits as a prospect until something moves - then you mark it client, and later past client if the work ends.
icon: lucide:route
name: Moves as you update it
- description: Only you see this, so keep it wherever you check next.
icon: lucide:user
name: Just for you
- description: Name the organization and where things stand.
icon: lucide:briefcase
name: New prospect
requirements:
- label: Organization
name: name
placeholder: Nordvik Bakery
type: text
- label: Contact
name: contact
placeholder: met at the bakers' conference, [email protected]
type: text
- label: Note
name: note
optional: true
placeholder: wants a booking flow, follow up after harvest season
type: textarea
name: Keep an organization on your radar
record_as: organizations
description: Write down an organization worth following up, and move it through client and past client as things develop.
followup: false
name: Is there an organization I should be keeping track of?
responsible:
contact: bl@uhhm.no
name: Bendik Aagaard Lynghaug
+15
View File
@@ -0,0 +1,15 @@
followup: true
name: So, what happens now?
description: >
That's on its way to a real inbox. Expect a considered reply within a
couple of days.
alternatives:
- name: In short
description: ""
features:
- name: We read it
description: In full, before we write back.
- name: We ask questions
description: >
Usually more than you'd expect. Good software begins with a
clear argument for what it's actually for.
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: If it looks like a fit, we'll reach out to talk about where you could help.
name: We'll write if there's a fit
- action: /
consequence:
- Back to site
description: ''
name: Back to site
description: Your note is in. An owner reads every one and will get back to you if there's a fit worth exploring.
name: What happens to what I just sent?
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: Expect a reply within a few working days, either way, straight to your inbox.
name: A real reply from someone who read it
- action: /
consequence:
- Back to home
description: ''
name: Back to home
description: Your brief is in. An owner reads every commission end to end before replying.
name: What happens now that we've sent our brief?
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: Expect a reply once your report has been looked at and the network checked.
name: A custodian is on it
- action: /
consequence:
- Back to the site
description: ''
name: Back to the site
description: A custodian reads every message about the redoal network and what it keeps.
name: Who looks after what the network keeps?
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: We'll write back once it's been read — approved and made live, or turned down with a reason.
name: A read, then a decision
- action: /
consequence:
- Back home
description: ''
name: Back home
description: Your proposal is in. Someone here will read it and decide whether it fits before anything changes on the page.
name: What happens to my proposal now?
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: You're on the list. Every note has an unsubscribe link if it stops being useful.
name: You're on the list
- action: /
consequence:
- Back to site
description: ''
name: Back to site
description: You'll hear from us when there's something worth saying, not on a schedule.
name: What happens now that I've subscribed?
-10
View File
@@ -1,10 +0,0 @@
alternatives:
- description: If it looks like a fit, we'll draft your site from your own words and send it back for your say before anything goes live.
name: A first draft, built from your own words
- action: /
consequence:
- Back to site
description: ''
name: Back to site
description: We read every trial request ourselves before anything gets built.
name: What happens now that you've sent this?
+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
+212
View File
@@ -0,0 +1,212 @@
name: What came in?
description: >
Sign in as an organizational owner to see what's come in.
alternatives:
- name: Applicants
description: ""
action: /review
consequence: [Confirm]
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: applicants
requires_group: owners
transitions:
- to: invited
label: Invite
- to: declined
label: Decline
- from: invited
to: active
label: Onboard
- from: invited
to: declined
label: Decline
- from: active
to: departed
label: Depart
- name: Organizations
description: >
Client relationships as tracked state — advanced as the
relationship becomes real.
action: /review
consequence: [Confirm]
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: organizations
requires_group: owners
transitions:
- from: prospect
to: client
label: Becomes client
- from: prospect
to: past_client
label: Archive
- from: client
to: past_client
label: Churn
- name: Record a prospect
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: The organization and one person there to reach.
requirements:
- name: name
label: Organization
- name: contact
label: Contact
- name: note
label: Anything worth remembering
type: textarea
optional: true
# A guest for a conversation on chat.uhhm.no: an account at id.uhhm.no
# with a passkey of their own, a place in cnats_users (which is what
# lets someone into the lobby, where the video calls are), and a mail
# saying when to turn up. The link lasts 24 hours.
- name: Invite to a conversation
description: >
Someone you want to talk to on chat.uhhm.no. They get a mail from
bl@uhhm.no with a link to make a passkey with us (valid for 24
hours) and the time to turn up in the lobby, where the video is.
action: /review
consequence: [Send the invite]
invite:
group: cnats_users
mail:
subject: "A conversation with {site_name}, {tidspunkt}"
body: |-
Hello {name},
You are invited to a conversation on chat.uhhm.no, {tidspunkt}.
First, make a passkey with us. Open the link below, add a
passkey, and press "Save" at the end: without Save it is not
kept. The link works until {expires}:
{link}
Your username is {username}.
When the time comes, go to https://chat.uhhm.no, sign in with
the passkey, and you land in the lobby. Press the video button
there, and we are in.
{notat}
features:
- name: ""
description: Who, and when to meet in the lobby.
requirements:
- name: name
label: Name
- name: email
label: Email
type: email
- name: tidspunkt
label: When
type: datetime-local
- name: notat
label: A line for them (optional)
type: textarea
optional: true
# Every invite sent from here, from portal's own invites bucket. The
# row carries the one-time link too, should the mail not reach them.
- name: Invited
description: >
Everyone invited from this desk, with their one-time link and when
it runs out. The mail went out by itself; the link is here should
it not reach them. A link that ran out can be sent again; mark an
invite done once they have signed in.
action: /review
consequence: [Confirm]
features:
- name: ""
description: One row per invite.
resource:
source: { kind: kv, bucket: portal_invites }
requires_group: owners
transitions:
- { from: open, to: done, label: Done }
- { from: expired, to: open, label: Send again }
- { from: expired, to: done, label: Done }
- name: Projects
description: ""
action: /review
consequence: [Confirm]
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: projects
requires_group: owners
transitions:
- to: accepted
label: Accept
- to: declined
label: Decline
- name: Subscribers
description: ""
action: /review
consequence: [Send]
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: subscribers
requires_group: owners
- name: Compose newsletter
description: >
Sends to everyone above - picked up by an automation watching
this question, not portal itself.
requirements:
- name: subject
label: Subject
- 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
+22
View File
@@ -0,0 +1,22 @@
followup: true
name: What should you expect?
description: >
We'll write when there's something worth your time — that's the
whole promise.
alternatives:
- name: You're on the list
description: ""
features:
- name: Rare, by design
description: A few considered notes a year — nothing more.
- name: Easy to leave
description: Every note carries a genuine, one-click unsubscribe.
- name: Unsubscribe
description: >
Using the link from one of our emails? Confirm below and we'll
stop writing.
self_transition:
bucket: subscribers
to: unsubscribed
label: Unsubscribe me
+15 -2
View File
@@ -5,7 +5,20 @@ hero:
kind: module
module: hero.js
# Who mail from this site comes from (portal 0.3.43+, delivered by gdo).
# The skin over portal's own stylesheet.
stylesheet: custom.css
# The iOS configuration profile at /profile.mobileconfig. Everything in it -
# hosts, ports, the DAV collection paths - comes from Stalwart's own PACC
# document, so there is nothing here to keep in step with the mail server.
# The profile carries no address or password; iOS asks at install.
profile:
pacc: https://mail.uhhm.no/.well-known/user-agent-configuration.json
name: uhhm
organization: uhhm
# Mail goes out through gdo on this host, which sends over JMAP as
# bl@uhhm.no; replies come back to the same address.
mail:
from: UHHM <portal@uhhm.no>
from: "Bendik Lynghaug <bl@uhhm.no>"
reply_to: bl@uhhm.no