Compare commits

...
62 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
Bendik Aagaard LynghaugandClaude Fable 5 77ff58e524 Docs: this repo runs the uhhm.no instance (PORTAL_RELEASE pin)
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 21:49:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 380593a998 Pin portal v0.1.0 - first semver release
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 21:45:45 +02:00
Bendik Aagaard Lynghaug bb117c1a1b Amend: GITEA_ is a reserved Actions secret-name prefix
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-24 21:26:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9b7162f2ca Showcase links prefer repo Website setting; fix star count field
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 1s
A private starred repo used to link visitors straight to a 404. The
link now uses the repo's Website setting when set, falls back to the
repo url only for public repos, and renders private repos without a
website as unlinked cards. Also: Gitea serializes the star count as
stars_count, not GitHub's stargazers_count - the stars chip had been
silently null and dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:27:39 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 9dbd30655f Picking a file loads its current content to edit in place
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 1s
proposed_yaml binds to the file select: the selected file's content
fetches through Gitea's contents API (base64-decoded by the jq
filter) and lands in the editor. On-site contributions no longer
start from a blank box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 13:50:29 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 b2423d64cf More accurately: "How can this website improve?"
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:21:33 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 3e2abceec8 Ask "How can we improve this website?" instead of commanding "Propose a change"
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
The title is the page's one question - and it should actually be one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:21:17 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 6443b9fdd4 Propose-a-change: link the questions repo as the reference
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
A linked card straight from Gitea's repo info - proposers can read
what exists before proposing against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:19:34 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 1a88212bd9 Propose-a-change: pick the file from the repo itself
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
target_path becomes a select whose options are the live listing of
questions/ from Gitea's contents API (the engine's existing
select-from-resource mechanism - no code change), with a free path
field kept for proposals that create a new file. No more hand-typed,
typo-able paths for existing pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:14:56 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 c1bd503efd State the proposal scope where proposals are made
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
The automation fences commits to questions/*.yaml and aggregates.yaml;
the page and README now say so, instead of promising "any file" and
silently failing after approval.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 00:23:05 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 627bdb807e Asked by the full name, not the account short name
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:47:14 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 bd0ba41281 Voice: join the narrative, don't bucket people into roles
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:35:02 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 c07f8c880d Drop the investor concern entirely
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
It was a worked example of the empathic pattern, not a feature: every
alternative should be empathic, informative, and helpful - information
that gives an answer, not necessarily a path to choose. The pattern
stays (voice rules, README); the investor pages, aggregate, review
alternative, and front-page gateway go. The investors KV bucket and
its test records linger in NATS unreferenced - purge at leisure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:33:53 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 176e2210a0 README: the voice rules
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
The title is the only question; alternatives are bold statements
argued against each other; explain why input is needed, then hand
over the field; never assume the visitor's role - give the concern
its own page and let the question nav surface it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:29:53 +02:00
19 changed files with 1199 additions and 222 deletions
+119
View File
@@ -0,0 +1,119 @@
name: Deploy instance
# This content repo owns its portal instance: which portal version runs,
# the service env, the systemd unit, and the Caddy route. The portal repo
# only publishes versioned release artifacts (uhhm/portal's Publish
# workflow); PORTAL_RELEASE below pins the one this site runs.
#
# Rolling out a new portal version = bumping PORTAL_RELEASE (a commit,
# so every rollout is auditable and revertable). Content-only changes
# never come through here - lint-and-reload hot-swaps those into the
# running instance over NATS.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- .gitea/workflows/deploy.yml
env:
PORTAL_RELEASE: v0.3.32
INSTANCE: uhhm-portal
jobs:
deploy:
runs-on: bare
steps:
# Instance config comes from THIS repo's Actions variables/secrets
# (Settings -> Actions) - not the portal repo's. Guard before
# touching anything on the host: a missing secret must fail the
# run, not silently write an empty value into the live env file.
- name: Check instance secrets are configured
run: |
set -eu
[ -n "${{ secrets.NATS_URL }}" ] || { echo "missing secret NATS_URL"; exit 1; }
[ -n "${{ secrets.OAUTH2_CLIENT_SECRET }}" ] || { echo "missing secret OAUTH2_CLIENT_SECRET"; exit 1; }
[ -n "${{ secrets.AUTOMATION_READ_TOKEN }}" ] || { echo "missing secret AUTOMATION_READ_TOKEN"; exit 1; }
[ -n "${{ secrets.PORTAL_GITEA_API_TOKEN }}" ] || { echo "missing secret PORTAL_GITEA_API_TOKEN"; exit 1; }
# uhhm/portal is public, so the asset download is anonymous. The
# app@ template's ExecStart is /srv/app/%i/current/%i - the shipped
# binary is named "portal", so link the instance name to it.
- name: Ship pinned portal release
run: |
set -euo pipefail
api="${{ github.server_url }}/api/v1/repos/uhhm/portal"
url=$(curl -sf "$api/releases/tags/$PORTAL_RELEASE" | jq -r '.assets[0].browser_download_url')
rel="/srv/app/$INSTANCE/releases/$PORTAL_RELEASE"
rm -rf "$rel"
mkdir -p "$rel"
curl -sfL "$url" | tar -xz -C "$rel"
ln -sfn portal "$rel/$INSTANCE"
- name: Write service env
run: |
cat > /etc/app/$INSTANCE.env <<EOF
NATS_URL=${{ secrets.NATS_URL }}
KANIDM_URL=${{ vars.KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.OAUTH2_CLIENT_SECRET }}
PUBLIC_URL=${{ vars.PUBLIC_URL }}
COOKIE_SECURE=true
# This repo is its own instance's content source.
CONTENT_REPO=${{ github.server_url }}/${{ github.repository }}
CONTENT_BRANCH=main
SITE_NAME=${{ vars.SITE_NAME }}
LEPTOS_SITE_ADDR=0.0.0.0:3010
# 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
# src/resource.rs) - content loading stays anonymous.
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
EOF
# Activate only after the release and env are fully written, so a
# failed download or missing config never takes the site down.
#
# 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
# attribute on the session cookie), but Kanidm's redirect_uri is
# fixed to PUBLIC_URL - redirecting www to the naked domain keeps
# every visit on one canonical host. The runner is in the docker
# group, so no sudo here either.
- name: Update Caddy routing
run: |
cat > /etc/caddy/services.d/$INSTANCE.caddy <<'EOF'
www.{$DOMAIN} {
redir https://{$DOMAIN}{uri} permanent
}
{$DOMAIN} {
reverse_proxy host.docker.internal:3010
log {
output file /var/log/caddy/www.log
}
}
EOF
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
+5 -5
View File
@@ -15,11 +15,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Same bare-metal runner/host as portal's own deploy job, which # Same bare-metal runner/host as this repo's own Deploy workflow,
# publishes this binary to a stable path on every deploy - runs # which ships question_lint alongside the portal binary in every
# portal's real transition-table/shape validation directly, not a # pinned release - so content is validated by the exact portal
# hand-maintained yq/jq subset of the same rules (lint.sh, now # version this instance runs, not a hand-maintained yq/jq subset
# superseded and removed). # of the same rules.
- name: Lint questions - name: Lint questions
run: /srv/app/uhhm-portal/current/question_lint --path questions run: /srv/app/uhhm-portal/current/question_lint --path questions
+214 -28
View File
@@ -37,6 +37,27 @@ What the runtime provides underneath:
decisions (send the newsletter, onboard an invited applicant, commit decisions (send the newsletter, onboard an invited applicant, commit
an approved development proposal). an approved development proposal).
## This repo runs the instance
Besides the content, this repo owns the uhhm.no portal instance
itself. `.gitea/workflows/deploy.yml` pins the portal version:
```yaml
env:
PORTAL_RELEASE: v0.2.3 # a release tag on uhhm/portal
```
Upgrading (or downgrading) portal is bumping that line and pushing —
the workflow downloads the pinned release artifact, ships it, rewrites
the instance env from this repo's Actions variables/secrets, restarts
`app@uhhm-portal`, and refreshes the Caddy route. Nothing else
deploys this site; a push to the portal repo only publishes a new
version for repos like this one to opt into.
Day to day you rarely touch it: content edits (any `*.yaml` here)
hot-reload without a deploy, and the deploy workflow only triggers on
changes to itself (or a manual run from the Actions tab).
## The optimal usecase, in order ## The optimal usecase, in order
A full concern — say, tracking a new kind of actor — is stood up A full concern — say, tracking a new kind of actor — is stood up
@@ -47,10 +68,10 @@ entirely in this repo, in this order:
transitions: transitions:
```yaml ```yaml
- bucket: investors - bucket: partners # illustrative
initial: open initial: open
states: states:
open: { event: expressed_interest } open: { event: introduced }
in_dialogue: { event: entered_dialogue } in_dialogue: { event: entered_dialogue }
committed: { event: committed } committed: { event: committed }
declined: { event: declined } declined: { event: declined }
@@ -68,12 +89,13 @@ entirely in this repo, in this order:
`record_as` naming the bucket: `record_as` naming the bucket:
```yaml ```yaml
- name: I want to invest - name: A bold statement to get behind
action: /invested # thank-you page to advance to action: /thanks # follow-up page to advance to
consequence: [Open the conversation] # the submit button's label consequence: [The button's label]
record_as: investors record_as: partners
features: features:
- name: Who are you? - name: A way to reach you
description: Say why the input is needed, then hand over the field.
requirements: requirements:
- name: email - name: email
label: Email label: Email
@@ -90,13 +112,13 @@ entirely in this repo, in this order:
every selection at once: every selection at once:
```yaml ```yaml
- name: Investors - name: Partners
action: /review action: /review
consequence: [Confirm] consequence: [Confirm]
features: features:
- name: "" - name: ""
resource: resource:
source: { kind: kv, bucket: investors } source: { kind: kv, bucket: partners }
requires_group: owners requires_group: owners
transitions: transitions:
- { to: in_dialogue, label: Open dialogue } - { to: in_dialogue, label: Open dialogue }
@@ -110,23 +132,59 @@ entirely in this repo, in this order:
infrastructure repo's `n8n-workflows/`). Rename a page or a label infrastructure repo's `n8n-workflows/`). Rename a page or a label
and its workflow must be updated in lockstep. and its workflow must be updated in lockstep.
5. **Change the system through itself.** `/develop-proposal` takes a 5. **Change the system through itself.** `/develop/proposal` takes a
proposed replacement for any file in this repo — from a person, or proposed replacement for any content file (the `questions/` tree,
eventually a locally-run model, through the same public form. An `aggregates.yaml`, or `site.yaml` — nothing else is in scope) —
owner approves it on `/develop`; approval triggers the automation from a person, or eventually a locally-run model, through the same
that branches, commits, opens a PR, and merges only when the same public form. The submission itself becomes a branch and a draft
lint that gates every human push passes. Nothing merges on pull request at once (n8n "Portal: proposal received"), so lint
autopilot. 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
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 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
questions.
- **Don't bucket people into roles.** We join the visitor's narrative
in the simplest way: statements they can get behind and information
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 (nested non-index files, or `followup: true`) only join
the nav once the visitor holds an answer chain.
## Schema quick reference ## Schema quick reference
The exact structs live in portal's `src/content.rs`; the shape: The exact structs live in portal's `src/content.rs`; the shape:
``` ```
Question id (doubles as the URL path), name, description, Question id (derived from the file's path - see routing
qualifies (Kanidm group gate), responsible {name, contact}, below - declare only to override), name,
alternatives[] description, qualifies (Kanidm group gate),
Alternative name, description, action, consequence[label], 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), encouragements[], images[] (1 = banner, 2+ = card deck),
record_as (bucket), self_transition {bucket, to, label}, record_as (bucket), self_transition {bucket, to, label},
features[] features[]
@@ -134,23 +192,151 @@ Question id (doubles as the URL path), name, description,
(Iconify name, e.g. lucide:star), requirements[], (Iconify name, e.g. lucide:star), requirements[],
resource resource
Requirement name, label, type, optional, multiple, placeholder, Requirement name, label, type, optional, multiple, placeholder,
accept (file), resource + id_field (select options) accept (file), resource + id_field (select options),
ResourceSpec source (kv | gitea_starred | gitea_org_repos | url), 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),
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}], 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`, Requirement `type`: `text` (default), `textarea`, `email`, `tel`,
`select`, `file`, `prosekit` (rich text), or any HTML input type. `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 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 `self_transition` is the anonymous, single-record counterpart to a
review resource's transitions — fireable by whoever holds one specific review resource's transitions — fireable by whoever holds one specific
record's `?chain=` link plus its matching email (the unsubscribe record's `?chain=` link plus its matching email (the unsubscribe
pattern). 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 ## Adding a page
Drop a `questions/<name>.yaml` with a unique `id`, reference that `id` Drop a YAML file where its URL should live (`questions/foo.yaml`
from some alternative's `action`, push. Lint runs, portal hot-reloads, `/foo`, `questions/flow/step.yaml` → `/flow/step`), reference it from
the page is live — no registration, no deploy. Or propose it through some alternative's `action`, push. Lint runs, portal hot-reloads, the
`/develop-proposal` and let the loop do the pushing. page is live — no registration, no deploy. Or propose it through
`/develop/proposal` and let the loop do the pushing.
-13
View File
@@ -54,19 +54,6 @@ aggregates:
prospect: [client, past_client] prospect: [client, past_client]
client: [past_client] client: [past_client]
- bucket: investors
initial: open
states:
open: { event: expressed_interest }
in_dialogue: { event: entered_dialogue }
committed: { event: committed }
declined: { event: declined }
divested: { event: divested }
transitions:
open: [in_dialogue, declined]
in_dialogue: [committed, declined]
committed: [divested]
# Proposals to change this very repo - the question architecture # Proposals to change this very repo - the question architecture
# dogfooding its own development. Approval hands off to an n8n # dogfooding its own development. Approval hands off to an n8n
# automation that opens a PR against this repo (see the infrastructure # automation that opens a PR against this repo (see the infrastructure
+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 followup: true
name: What happens now that you've applied? name: What happens now that you've applied?
description: > description: >
-43
View File
@@ -1,43 +0,0 @@
id: /develop-proposal
route: ^/develop-proposal
name: Propose a change
description: >
This site is built on the same question architecture you're using
right now — and changes to it are proposed through it. Describe what
should change and why; an owner reviews every proposal, and approved
ones become real commits.
alternatives:
- name: The change you propose
description: >
The proposed content replaces (or creates) one file in the
questions repo, verbatim — so it has to be complete, valid YAML
for that file, not a diff.
action: /proposed
consequence: [Propose it]
record_as: development_proposals
encouragements:
- Every proposal gets read by a person before anything moves.
features:
- name: Sign it
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
description: ""
requirements:
- name: target_path
label: File path
placeholder: questions/example.yaml
- name: rationale
label: The rationale
type: textarea
- name: proposed_yaml
label: Proposed file content (complete YAML)
type: textarea
@@ -1,6 +1,4 @@
id: /develop name: What's been proposed?
route: ^/develop
name: Development proposals
qualifies: owners qualifies: owners
description: > description: >
Proposed changes to the questions repo, awaiting a decision. Proposed changes to the questions repo, awaiting a decision.
+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"
@@ -1,7 +1,7 @@
id: /proposed # Reached from the proposal form only - and provably so: the visitor
route: ^/proposed # must hold a ?chain= lineage ending at an answer to proposal.yaml.
followup: true requires_chain: proposal
name: Proposal received name: What happens to your proposal?
description: > description: >
It's in the queue. An owner reads every proposal in full — approved 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 ones open a real pull request, and the same checks every human
+19 -13
View File
@@ -1,9 +1,8 @@
id: /
name: What are you here for? name: What are you here for?
description: > description: >
Strategies that address problems — applied in software. Strategies that address problems — applied in software.
responsible: responsible:
name: bl name: Bendik Aagaard Lynghaug
contact: bl@uhhm.no contact: bl@uhhm.no
alternatives: alternatives:
- name: Good software begins with a clear argument - name: Good software begins with a clear argument
@@ -17,16 +16,26 @@ alternatives:
- We read every word before breaking ground. - We read every word before breaking ground.
features: features:
- name: What we've built - name: What we've built
icon: lucide:layers
description: A few things already out in the world. description: A few things already out in the world.
resource: resource:
source: source:
kind: gitea_starred kind: gitea_starred
username: bl username: bl
public: true 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: > jq: >
.[] | {name: .name, description: .description, url: .html_url, .[] | {name: .name, description: .description,
stars: .stargazers_count} url: (if .website != null and .website != "" then .website
elif .private then null
else .html_url end)}
- name: Your brief - name: Your brief
icon: lucide:file-text
description: > description: >
A few lines are enough to begin a conversation — we need a A few lines are enough to begin a conversation — we need a
name and an address because the reply is personal, and the name and an address because the reply is personal, and the
@@ -58,7 +67,10 @@ alternatives:
- Consider yourself posted. - Consider yourself posted.
features: features:
- name: Where to send it - 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: requirements:
- name: name - name: name
label: Name label: Name
@@ -66,14 +78,6 @@ alternatives:
label: Email label: Email
type: email type: email
- name: Capital needs reasons
description: >
Whoever manages it is accountable for them — enthusiasm doesn't
qualify. Ours are written down, as qualities you can test
against your own.
action: /invest
consequence: [Read our reasons]
- name: Work happens through dialogue, not tickets - name: Work happens through dialogue, not tickets
description: > description: >
What you bring shapes what gets built. We onboard collaborators What you bring shapes what gets built. We onboard collaborators
@@ -86,6 +90,7 @@ alternatives:
- Knock — we actually answer. - Knock — we actually answer.
features: features:
- name: A way to reach you - name: A way to reach you
icon: lucide:at-sign
description: The reply is personal — it needs a name and an address. description: The reply is personal — it needs a name and an address.
requirements: requirements:
- name: name - name: name
@@ -94,6 +99,7 @@ alternatives:
label: Email label: Email
type: email type: email
- name: Your angle - name: Your angle
icon: lucide:compass
description: > description: >
We build around what each person actually brings — say what We build around what each person actually brings — say what
that is, in your own words. that is, in your own words.
-57
View File
@@ -1,57 +0,0 @@
id: /invest
route: ^/invest
name: How do we invest in uhhm?
description: >
If you manage capital, enthusiasm isn't a reason — you're accountable
for the qualities an investment actually has. Here are ours, written
down, and the shortest path to testing them against your own.
alternatives:
- name: What a good investment should be able to show
description: ""
features:
- name: State you can audit
icon: lucide:git-commit-horizontal
description: >
Every applicant, client, project, and investor here is
tracked state with a durable event history — not a
spreadsheet someone remembers to update. What we claim about
the organisation, the system can show.
- name: A focus that doesn't wander
icon: lucide:compass
description: >
Distributed communication architecture is the mandate. Work
that doesn't serve it doesn't get taken on — including work
that pays.
- name: Growth by dialogue, not headcount
icon: lucide:messages-square
description: >
People, clients, and changes to the system itself all arrive
through the same deliberate, reviewable loop. Slow on
purpose, cheap to run, hard to bloat.
- name: Test us
description: >
A few lines about what you're looking for opens the dialogue —
commitments only ever follow real conversation.
action: /invested
consequence: [Open the conversation]
record_as: investors
encouragements:
- Serious inquiries get a serious answer.
features:
- name: A way to reach you
description: Dialogue needs a counterpart — the reply is personal.
requirements:
- name: name
label: Name
- name: email
label: Email
type: email
- name: Your criteria
description: >
We answer against what you're actually accountable for — so
state it plainly.
requirements:
- name: motivation
label: Your criteria for a good investment
type: textarea
-17
View File
@@ -1,17 +0,0 @@
id: /invested
route: ^/invested
followup: true
name: What happens now?
description: >
Your interest is on its way to a real inbox. Expect a considered,
personal reply — not a pitch deck on autopilot.
alternatives:
- name: In short
description: ""
features:
- name: We read it
description: In full, before we write back.
- name: Dialogue before commitment
description: >
We'll want to understand each other properly first — what
you're looking for, and whether we're actually it.
-2
View File
@@ -1,5 +1,3 @@
id: /project
route: ^/project
followup: true followup: true
name: So, what happens now? name: So, what happens now?
description: > 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 name: What came in?
route: ^/review
name: The review desk
qualifies: owners
description: > description: >
Sign in as an organizational owner to see what's come in. Sign in as an organizational owner to see what's come in.
alternatives: alternatives:
@@ -32,33 +29,6 @@ alternatives:
to: departed to: departed
label: Depart label: Depart
- name: Investors
description: ""
action: /review
consequence: [Confirm]
features:
- name: ""
description: ""
resource:
source:
kind: kv
bucket: investors
requires_group: owners
transitions:
- to: in_dialogue
label: Open dialogue
- to: declined
label: Decline
- from: in_dialogue
to: committed
label: Commit
- from: in_dialogue
to: declined
label: Decline
- from: committed
to: divested
label: Divest
- name: Organizations - name: Organizations
description: > description: >
Client relationships as tracked state — advanced as the Client relationships as tracked state — advanced as the
@@ -85,13 +55,17 @@ alternatives:
label: Churn label: Churn
- name: Record a prospect - 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 action: /review
consequence: [Record] consequence: [Record]
record_as: organizations record_as: organizations
encouragements:
- Written down is halfway to followed up.
features: features:
- name: "" - name: ""
description: "" description: The organization and one person there to reach.
requirements: requirements:
- name: name - name: name
label: Organization label: Organization
@@ -142,3 +116,26 @@ alternatives:
- name: body - name: body
label: Body label: Body
type: prosekit 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 followup: true
name: What should you expect? name: What should you expect?
description: > 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