Compare commits

...
28 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 8376c68f8f wordmark_ink: an SVG wordmark drawn in the page's own ink, following light and dark everywhere
Test / test (pull_request) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 09:33:27 +02:00
Bendik Aagaard Lynghaug c8901558b2 Release 0.6.10
Test / test (push) Successful in 1m18s
Publish release / publish (push) Successful in 2m1s
2026-09-30 21:52:28 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 02c94ae5dc Changelog: 0.6.10
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:52:25 +02:00
bl 5fc29fe5a6 Merge pull request 'Who we are, on its own page; inside forms ask real questions' (#19) from present-yourself into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 19:52:24 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 e6c4f67db7 Who a business is gets a page of its own; inside forms ask real questions; build names untried needs
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:52:23 +02:00
Bendik Aagaard Lynghaug 19b7750292 Release 0.6.9
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m54s
2026-09-30 21:38:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 1a1723f8bb Changelog: 0.6.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:56 +02:00
bl 0d67675456 Merge pull request 'A bio is markdown, on stricter rules' (#18) from bio-markdown into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 19:38:55 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 eb982ec6e6 A bio is markdown, on stricter rules than the site's own
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:54 +02:00
Bendik Aagaard Lynghaug c6dffc384c Release 0.6.8
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 2m0s
2026-09-30 21:08:33 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db0972974f Changelog: 0.6.8
docs / deploy (push) Successful in 4s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:30 +02:00
bl 72faf44559 Merge pull request 'An upload that did not arrive says why' (#17) from upload-messages into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 19:08:30 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db89ea6400 An upload that did not arrive says why
Test / test (pull_request) Successful in 1m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:28 +02:00
Bendik Aagaard Lynghaug 95bffc221b Release 0.6.7
Test / test (push) Successful in 1m19s
Publish release / publish (push) Successful in 2m8s
2026-09-30 20:50:44 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f5266cb347 Changelog: 0.6.7
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:42 +02:00
bl 248561e805 Merge pull request 'leaves_after: finished records leave their lists' (#16) from leaves-after into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 18:50:41 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0dc25b4023 A record may leave its lists some time after reaching a state: leaves_after
Test / test (pull_request) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:28 +02:00
Bendik Aagaard Lynghaug 96c513d912 Release 0.6.6
Test / test (push) Successful in 1m16s
Publish release / publish (push) Successful in 2m7s
2026-09-30 20:39:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0cb1dda202 Changelog: 0.6.6
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:10 +02:00
bl ec9bf10b72 Merge pull request 'A card of one's own; no double sends; refusals shown' (#15) from own-card into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
2026-09-30 18:39:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 809a6bdfe8 A card of one's own: one_per_person, prefilled, replaced on send; no double sends; refusals are shown
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:01 +02:00
Bendik Aagaard Lynghaug cae116b50c Release 0.6.5
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m54s
2026-09-30 20:10:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d8528ff1f9 Changelog: 0.6.5
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:10:56 +02:00
bl 6c2e0cc299 Merge pull request 'A need may be paused' (#14) from paused-needs into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 18:10:56 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9368086d81 A need may be paused: its card stays, with no form and a button that does not press
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:09:14 +02:00
Bendik Aagaard Lynghaug e2497941c8 Release 0.6.4
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:52:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 55fc61c514 Changelog: 0.6.4
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:52:10 +02:00
bl 3b17e6440e Merge pull request 'Rate limit: 100 at once, 10 a second; the page's own files uncounted' (#13) from rate-limit into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:52:09 +00:00
23 changed files with 965 additions and 103 deletions
+35 -1
View File
@@ -4,7 +4,41 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## Unreleased
## 0.6.11 (2026-10-01)
- `wordmark_ink: true` in `site.yaml`: an SVG wordmark is drawn in the page's own ink, as a mask over the text colour, so it follows the page's light and dark in every browser. A wordmark shown as an `<img>` only knows the system's setting, and some browsers don't pass even that on. Opacity in the file still shows, so a lighter line stays lighter. Opt-in, for one-colour marks; a mark with a colour of its own keeps its colours.
## 0.6.10 (2026-09-30)
- Trainer: who a business is gets a page of its own ("who are we?", `/hvem` in Norwegian), with nothing to do on it. It has one card per need that `presents` (the people, the work, each shown as picture cards) and one for the plan's `values`. The front door leads there instead of carrying it all.
- Trainer: the words model is told that a form page from the inside asks a real question and says where the answer ends up ("How do you present yourself?", not "Your card on the front page"), and that a `one_per_person` form is the colleague's own.
- Trainer: `portal build` names every need no persona tries. Klingenberg Bygg's plan had personas only for customers, so nobody ever played the office filling in its own forms.
- Klingenberg Bygg: "Hvem består Klingenberg Bygg av?" with Menneskene, Erfaringer (projects with pictures, added from the office) and Verdier (a first draft for Fredrik to replace).
## 0.6.9 (2026-09-30)
- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
## 0.6.8 (2026-09-30)
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
## 0.6.7 (2026-09-30)
- `leaves_after: { minutes: 10 }` on a state: a record that has been there that long leaves its bucket's lists (days, hours and minutes add up; swept every minute; the event log keeps it). Trainer: `leaves_after` per stage on a need. A Klingenberg Bygg card taken off the front page leaves the office's list ten minutes later instead of standing there greyed out.
## 0.6.6 (2026-09-30)
- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page.
- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice.
- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not.
- A refused send says why, under its button. It used to say nothing.
## 0.6.5 (2026-09-30)
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
## 0.6.4 (2026-09-30)
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.3"
version = "0.6.10"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.3"
version = "0.6.10"
edition = "2021"
[lib]
+5 -1
View File
@@ -24,6 +24,7 @@ on every hot reload, and in CI through `iris check`.
```yaml
title: Vestmark kommune # the browser title and the header
wordmark: /site/logo.svg # an image instead of the title
wordmark_ink: true # a one-colour SVG mark drawn in the page's own ink
wordmark_invert: true # flip it in dark mode
wordmark_height: 2.4 # rem
lang: no # the interface language: en or no
@@ -275,7 +276,10 @@ time. The cards appear in the order the records were put up. That is
how a site shows the people who work there, or a gallery of what it
has built. On a desk, the same picture is a thumbnail that opens.
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
send a JPEG whatever it keeps its photos as.
send a JPEG whatever it keeps its photos as. What the person wrote is shown as
markdown on stricter rules than the site's own: paragraphs, line
breaks as typed, lists, emphasis and links to https, mailto or tel
(marked `nofollow ugc`); no images, no raw HTML.
`states` and `where` narrow a listing. They matter most for a register
that is public by law — public about what was decided, not about what
+16
View File
@@ -116,6 +116,22 @@ the same transition path, fires the same mail, and lands on
The unit is days and the sweep is hourly. This is a deadline, not a
job queue.
### leaves_after
```yaml
taken_down:
event: taken_down
leaves_after: { minutes: 10 }
```
How long a record stays in its bucket's lists once it has entered the
state, when that is not for ever. A card taken off the front page
leaves the desk's list ten minutes later instead of standing there
greyed out, and the ten minutes are the time to see what was just
done. Days, hours and minutes add up, and a sweep takes records out
once a minute. Nothing is decided and no mail goes. The record leaves
the lists, and the event log keeps it.
## attended_by
```yaml
+45 -12
View File
@@ -38,16 +38,18 @@ does:
godkjenning innen flere fagområder. Når tilbudet er godtatt,
følger du oppdraget og henter dokumentene innlogget.
# Hvem vi er: erfaringen, i våre egne ord, og menneskene. Verdiene
# skriver Fredrik selv; de legges inn her som en `values`-linje til.
# Hvem vi er: en egen side med menneskene, erfaringene (oppdrag med
# bilder, lagt inn fra kontoret) og verdiene under. Verdiene er et
# utkast i husets tone; Fredrik bytter dem ut når han vil.
values:
- id: erfaring
text: >
Skoler i drift, brannstasjoner, omsorgsboliger, kantina på Det
Norske Teatret og en Munch-eiendom på Hvitsten. Vi har gått over,
prosjektert og fulgt opp bygg for kommuner, borettslag, bedrifter
og private, fra borprøver i gammel mur til kunstgress på
Fredrikstad stasjon.
- id: mal-to-ganger
text: Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.
- id: bygget-forst
text: Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.
- id: ingen-overraskelser
text: Det vi finner, får du vite. Også sprekkene.
- id: vannet
text: Vannet finner alltid en vei. Derfor leter vi etter den først.
needs:
- id: tilstand
@@ -100,7 +102,9 @@ needs:
- { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true }
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
- id: soknad
paused: true
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
wants: en godkjent byggesøknad, med ansvarsrett der det kreves
lands_in: soknader
@@ -143,12 +147,15 @@ needs:
- { name: hva }
- { name: dokument, type: file }
# Menneskene: kontoret legger inn hver ansatt med navn, rolle,
# erfaring og portrett, og forsiden viser dem alle.
# Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle,
# erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned.
- id: ansatt
who: kontoret, når noen begynner hos oss eller skal ha nytt portrett
who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan
wants: at den som vurderer oss ser hvem som gjør jobben
enters_as: kontor
one_per_person: true
# Et kort som er tatt ned, forsvinner fra kontorets liste etter ti minutter.
leaves_after: { sluttet: { minutes: 10 } }
lands_in: ansatte
handled_by: kontor
watched_by: [public]
@@ -165,6 +172,27 @@ needs:
- { name: erfaring, type: textarea }
- { name: portrett, type: picture }
# Erfaringene: oppdrag vi vil vise fram, med bilde og noen løse ord.
- id: erfaring
who: en av oss på kontoret, når et oppdrag er verdt å vise fram
wants: at den som vurderer oss ser hva vi har gjort
enters_as: kontor
lands_in: erfaringer
handled_by: kontor
watched_by: [public]
presents: true
public_states: [open]
stages: [open, fjernet]
stages_text: { open: "vises", fjernet: "fjernet" }
done_when: [fjernet]
success: [fjernet]
leaves_after: { fjernet: { minutes: 10 } }
max_fields: 3
collects:
- { name: tittel }
- { name: bilde, type: picture }
- { name: om, type: textarea }
personas:
- { id: kommune-skole, need: tilstand, text: "Vi i kommunen skal legge fram en sak om rehabilitering av en barneskole fra 1972 og trenger en tilstandsanalyse politikerne kan beslutte ut fra." }
- { id: borettslag, need: tilstand, text: "Styret i borettslaget lurer på hva som haster mest av tak, fasade og rør. Vi trenger noen som kan gå over byggene og si hva det står til." }
@@ -173,6 +201,11 @@ personas:
- { id: skole-i-drift, need: prosjekt, text: "Skolen skal utvides mens den er i drift. Vi trenger en beskrivelse og en fremdriftsplan med byggetrinn." }
- { id: soke-garasje, need: soknad, text: "Tegningene til garasjen er ferdige, men jeg aner ikke hvordan jeg søker kommunen. Kan dere ta søknaden?" }
- { id: ansvarsrett, need: soknad, text: "Vi har prosjektert ombyggingen selv, men mangler noen med ansvarsrett som kan stå for søknaden." }
# Innenfra: det kontoret selv fyller ut.
- { id: rapport-klar, need: leveranse, text: "Tilstandsrapporten til borettslaget er ferdig. Jeg vil at styreleder skal få den og vite at den ligger der." }
- { id: ny-hos-oss, need: ansatt, text: "Jeg har nettopp begynt hos Klingenberg Bygg og vil at kundene skal se hvem jeg er og hva jeg kan." }
- { id: nytt-portrett, need: ansatt, text: "Portrettet mitt på forsiden er fra da jeg begynte. Jeg vil bytte det uten å skrive alt det andre på nytt." }
- { id: brannstasjonen, need: erfaring, text: "Brannstasjonen i Ytre Enebakk står ferdig, og vi har gode bilder fra fundamenteringen. Den vil jeg vise fram." }
slideshow:
max_height: 60
+131 -34
View File
@@ -109,25 +109,14 @@
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
}
},
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
}
},
"who": {
"heading": "Hvem består Klingenberg Bygg av?",
"description": "Mennesker, verdier og erfaringer.",
"items": {
"erfaring": {
"heading": "Erfaringene",
"description": "Skoler i drift, brannstasjoner, omsorgsboliger, kantina på Det Norske Teatret og en Munch-eiendom på Hvitsten. Vi har gått over, prosjektert og fulgt opp bygg for kommuner, borettslag, bedrifter og private, fra borprøver i gammel mur til kunstgress på Fredrikstad stasjon.",
"icon": "award"
},
"people-ansatte": {
"heading": "Menneskene",
"description": "De som går befaringene, skriver rapportene og står for søknadene.",
"empty": "Snart presenterer vi oss her.",
"icon": "users"
}
}
"description": "Mennesker, erfaringer og verdier.",
"button": "Bli kjent med oss"
}
},
"questions/received/tilstand.yaml": {
@@ -257,12 +246,21 @@
},
"ansatte": {
"heading": "Ansatte på forsiden",
"description": "Menneskene slik besøkende ser dem.",
"description": "Menneskene slik besøkende ser dem. Hver retter sitt eget kort; herfra kan et kort tas ned.",
"moves": {
"open->sluttet": "Tatt av forsiden"
},
"list_title": "Ansatte",
"list_note": "Hver rad er én person på forsiden."
},
"erfaringer": {
"heading": "Erfaringer",
"description": "Oppdragene slik besøkende ser dem.",
"moves": {
"open->fjernet": "Fjern"
},
"list_title": "Erfaringer",
"list_note": "Hver rad er ett oppdrag som vises nå."
}
},
"links": {
@@ -272,8 +270,13 @@
"button": "Ny leveranse"
},
"ansatt": {
"heading": "Legg inn en ansatt",
"description": "Navn, rolle, erfaring og portrett, rett ut på forsiden.",
"heading": "Hvordan presenterer du deg?",
"description": "Kortet ditt på forsiden: rolle, erfaring og portrett.",
"button": "Åpne"
},
"erfaring": {
"heading": "Hvilket oppdrag vil du vise fram?",
"description": "Et bilde og noen løse ord, blant erfaringene.",
"button": "Åpne"
}
},
@@ -470,10 +473,10 @@
}
},
"questions/kontor/ansatt-sent.yaml": {
"question": "Lagt ut",
"description": "Den ansatte vises nå blant menneskene på forsiden.",
"next_heading": "Skal noen ned igjen?",
"next_description": "Trykk «Tatt av forsiden» på raden deres i listen over ansatte på kontoret. Nytt portrett: legg personen inn på nytt, og ta den gamle raden av.",
"question": "Kortet ditt er lagret",
"description": "Slik står du nå på forsiden.",
"next_heading": "Vil du endre noe?",
"next_description": "Åpne «Hvordan presenterer du deg?» igjen: kortet ditt er fylt ut med det du har, og det du lagrer erstatter det.",
"see_all": {
"heading": "Se alle ansatte",
"description": "",
@@ -481,21 +484,21 @@
}
},
"questions/kontor/ansatt.yaml": {
"heading": "Legg inn en ansatt",
"description": "Navn, rolle, erfaring og portrett. Den ansatte vises på forsiden så snart du sender.",
"button": "Legg ut",
"heading": "Kortet ditt",
"description": "Rett det du vil og lagre. Du har ett kort, og det du lagrer erstatter det som står der nå.",
"button": "Lagre kortet",
"encouragement": "",
"icon": "user-plus",
"icon": "id-card",
"requires_title": "",
"requires_description": "",
"requires_icon": "",
"road_title": "Hva skjer så",
"road_description": "Personen vises på forsiden til du tar dem av igjen fra kontoret.",
"road_title": "Når du lagrer",
"road_description": "Kortet ditt på forsiden endres med en gang.",
"road_icon": "route",
"who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Portrettet vises som det er, så velg et bilde du er fornøyd med.",
"who_description": "Alle som besøker siden. Navnet ditt er det du er logget inn med.",
"who_icon": "users",
"form_title": "Den ansatte",
"form_title": "Kortet ditt",
"form_description": "",
"labels": {
"name": "Navn",
@@ -506,11 +509,11 @@
"hints": {
"name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
"erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av",
"portrett": "Et stående bilde, gjerne tatt i dagslys"
"erfaring": "Utdanning, fagområder, bygg du er stolt av. Du kan skrive **fet**, *kursiv*, lister med - og [lenker](https://…).",
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
},
"question": "Hvem skal vises på forsiden?",
"page_description": ""
"question": "Hvordan presenterer du deg?",
"page_description": "Det du skriver her, står på kortet ditt blant menneskene på forsiden."
},
"questions/status/ansatte/index.yaml": {
"question": "Hvem består Klingenberg Bygg av?",
@@ -521,5 +524,99 @@
"list_note": "",
"empty": "Snart presenterer vi oss her.",
"answer": ""
},
"questions/hvem.yaml": {
"question": "Hvem består Klingenberg Bygg av?",
"description": "Menneskene som gjør jobben, oppdragene vi har gjort, og det vi står for.",
"presents": {
"ansatte": {
"heading": "Menneskene",
"description": "De som går befaringene, skriver rapportene og står for søknadene.",
"empty": "Snart presenterer vi oss her."
},
"erfaringer": {
"heading": "Erfaringer",
"description": "Et utvalg oppdrag, med bilder.",
"empty": "Snart viser vi fram oppdrag her."
}
},
"values": {
"heading": "Verdier",
"description": "Det vi står for.",
"items": {
"mal-to-ganger": {
"heading": "Mål to ganger",
"description": "Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.",
"icon": "ruler"
},
"bygget-forst": {
"heading": "Bygget først",
"description": "Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.",
"icon": "building"
},
"ingen-overraskelser": {
"heading": "Ingen overraskelser i veggen",
"description": "Det vi finner, får du vite. Også sprekkene.",
"icon": "search"
},
"vannet": {
"heading": "Vannet finner en vei",
"description": "Vannet finner alltid en vei. Derfor leter vi etter den først.",
"icon": "droplet"
}
}
}
},
"questions/kontor/erfaring.yaml": {
"heading": "Et oppdrag med bilde",
"description": "Et bilde og noen løse ord om hva vi gjorde. Det vises med en gang.",
"button": "Vis fram",
"encouragement": "",
"icon": "image-plus",
"paused": "",
"requires_title": "",
"requires_description": "",
"requires_icon": "",
"road_title": "Når du sender",
"road_description": "Oppdraget vises blant erfaringene med en gang, til noen på kontoret fjerner det.",
"road_icon": "route",
"who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Skriv ikke noe om kunden de ikke ville lest selv.",
"who_icon": "users",
"form_title": "Oppdraget",
"form_description": "",
"labels": {
"tittel": "Hva var det?",
"bilde": "Bilde",
"om": "Noen ord om det"
},
"hints": {
"tittel": "For eksempel: Brannstasjonen i Ytre Enebakk",
"bilde": "Et bilde fra oppdraget, gjerne liggende",
"om": "Hva vi gjorde, og hva som var verdt å gjøre skikkelig. **Fet**, *kursiv*, lister og lenker går an."
},
"question": "Hvilket oppdrag vil du vise fram?",
"page_description": "Det du legger inn her, står blant erfaringene på «Hvem består Klingenberg Bygg av?»."
},
"questions/kontor/erfaring-sent.yaml": {
"question": "Oppdraget vises nå",
"description": "Det står blant erfaringene på «Hvem består Klingenberg Bygg av?».",
"next_heading": "Skal det ned igjen?",
"next_description": "Trykk «Fjern» på raden i listen over erfaringer her på kontoret. Det forsvinner fra listen ti minutter etter.",
"see_all": {
"heading": "Se alle erfaringer",
"description": "",
"button": "Åpne"
}
},
"questions/status/erfaringer/index.yaml": {
"question": "Hva har Klingenberg Bygg gjort?",
"description": "Et utvalg oppdrag, med bilder.",
"heading": "Erfaringer",
"list_description": "",
"list_title": "Erfaringer",
"list_note": "",
"empty": "Snart viser vi fram oppdrag her.",
"answer": ""
}
}
+39
View File
@@ -51,6 +51,35 @@ pub struct AggregateSchema {
/// no answer in six weeks has to go on without one, and nothing in
/// a building is going to press that button.
pub after_for_state: HashMap<String, AfterSpec>,
/// How long a record stays in the list once it has entered a
/// state, when that is not for ever: a card taken off the front
/// page leaves the desk's list a little later, instead of standing
/// there greyed out. The event log keeps it.
pub leaves_for_state: HashMap<String, LeavesSpec>,
}
/// `leaves_after: { minutes: 10 }` on a state: how long a record that
/// has entered it stays in its bucket's list. Days, hours and minutes
/// add up; the sweep that takes it out runs every minute.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LeavesSpec {
#[serde(default, skip_serializing_if = "is_zero")]
pub days: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub hours: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub minutes: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
impl LeavesSpec {
pub fn millis(&self) -> i64 {
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
}
}
/// A mail to send when a record enters a state. `to` is `submitter`
@@ -125,6 +154,8 @@ struct RawState {
grants: Option<String>,
#[serde(default)]
after: Option<AfterSpec>,
#[serde(default)]
leaves_after: Option<LeavesSpec>,
}
#[derive(Debug, serde::Deserialize)]
@@ -197,7 +228,14 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
let mut mail_for_state = HashMap::new();
let mut grants_for_state = HashMap::new();
let mut after_for_state = HashMap::new();
let mut leaves_for_state = HashMap::new();
for (state, raw_state) in raw_schema.states {
if let Some(leaves) = raw_state.leaves_after {
if leaves.millis() == 0 {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: leaves_after needs some days, hours or minutes");
}
leaves_for_state.insert(state.clone(), leaves);
}
if let Some(group) = raw_state.grants {
if group.trim().is_empty() {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
@@ -245,6 +283,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
mail_for_state,
grants_for_state,
after_for_state,
leaves_for_state,
},
);
}
+67
View File
@@ -114,6 +114,73 @@ pub async fn store_answer(
Ok(())
}
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// it (the event log keeps them). Run after every store, so two sends
/// racing each other - a double click - both settle on the same one.
#[cfg(feature = "ssr")]
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
let own = own_records(js, bucket, username).await;
let Ok(store) = js.get_key_value(bucket).await else { return };
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
let Some(user) = user else { return };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
if req.kind == "hidden" {
continue;
}
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same
/// alternative.
+152 -6
View File
@@ -973,10 +973,29 @@ fn Hero(
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
// In the page's ink (`wordmark_ink`): a span the file masks, sized
// by the file's own proportions through an invisible copy of it,
// and named for screen readers as the image would have been.
let ink = site.wordmark_ink;
let mark = move || {
if ink {
let mask = format!("--wordmark-src:url(\"{}\")", wordmark.replace('"', "%22"));
view! {
<a class="wordmark wordmark-ink" href="/" aria-label=site_title.clone()>
<span class="wordmark-ink-mark" style=mask>
<img src=wordmark.clone() alt="" aria-hidden="true" style=wordmark_style.clone()/>
</span>
</a>
}
.into_any()
} else {
view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
}
.into_any()
}
};
view! {
@@ -1198,9 +1217,11 @@ fn AlternativeCard(
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_default();
} else if req.kind == "select" {
// Preset by a card of one's own (`answers::fill_own`).
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
select_field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(Vec::new()));
.or_insert_with(|| RwSignal::new(preset));
select_multi.insert(req.name.clone(), req.multiple);
} else {
// Widget-owned fields hold JSON objects, not strings.
@@ -1238,6 +1259,11 @@ fn AlternativeCard(
let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone();
// From the moment Send is pressed until the server has answered:
// files upload first, and a second press in that time used to send
// the form twice.
let sending = RwSignal::new(false);
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
@@ -1266,6 +1292,8 @@ fn AlternativeCard(
let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone();
sending.set(true);
send_error.set(None);
leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() {
@@ -1301,12 +1329,25 @@ fn AlternativeCard(
};
let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue };
// Too big is known before a byte is sent.
if file.size() > MAX_UPLOAD_BYTES as f64 {
send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string()));
sending.set(false);
return;
}
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key));
}
// A form sent without the file it asked for is a
// record that looks complete and is not: stop, and
// say so.
Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}");
let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok()));
send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string()));
sending.set(false);
return;
}
}
}
@@ -1319,6 +1360,7 @@ fn AlternativeCard(
parent_hash: parent_hash_for_submit,
responses_json,
});
sending.set(false);
});
};
@@ -1512,9 +1554,26 @@ fn AlternativeCard(
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
// On a card of one's own, the file already
// there (see `answers::fill_own`).
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
view! {
<span class="field-current">
<span>{move || t(&lang().get(), "current_file")}</span>
{if picture {
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
} else {
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
}}
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
</span>
}
});
return view! {
<label class="field" for=label_for>
{label_text}
{current}
<input
id=field_id
type="file"
@@ -1826,12 +1885,24 @@ fn AlternativeCard(
class="alt-submit"
disabled=move || {
(!has_requirements && pending_transitions.get().is_empty())
|| sending.get()
|| submit.pending().get()
|| transition_batch.pending().get()
}
>
{button_label.clone()}
</button>
// Why nothing happened, when something
// was refused: it used to say nothing.
{move || {
send_error
.get()
.or_else(|| match submit.value().get() {
Some(Err(e)) => Some(server_message(&e)),
_ => None,
})
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
}}
</form>
}
.into_any()
@@ -2238,6 +2309,31 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// The largest file one upload may carry. Here, not in `upload.rs`,
/// so the browser checks the same number before it sends anything.
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
/// Why an upload did not arrive, in words a person can act on, from
/// the status `POST /upload` answered with (see `upload.rs`): too big,
/// a site that takes no files, a page they may not send from, or
/// anything else.
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
fn upload_refusal(status: Option<u16>) -> &'static str {
match status {
Some(413) => "upload_too_large",
Some(503) => "upload_unavailable",
Some(403) => "upload_forbidden",
_ => "upload_failed",
}
}
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
let text = e.to_string();
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
}
/// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
@@ -2476,7 +2572,12 @@ fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl In
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4>
{rest.into_iter().map(|t| view! { <p>{t}</p> }).collect_view()}
// What the person wrote, as they wrote it: markdown, on
// the stricter rules for text that is not the site's.
{rest
.into_iter()
.map(|t| view! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> })
.collect_view()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
@@ -2559,6 +2660,7 @@ pub async fn get_question(
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref());
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
let mut chain_gate = None;
if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required
@@ -2912,6 +3014,26 @@ pub async fn submit_answer(
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// One card each: whose it is comes from the session, and a file the
// person did not choose again is the one already on their card.
let owner = if alt.one_per_person {
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
if let Some(map) = responses.as_object_mut() {
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
map.insert(req.name.clone(), kept.clone());
}
}
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
}
Some((bucket, user.username.clone()))
} else {
None
};
// A record that belongs to another record: check the other one is
// there before this one is written, so a reference in a stored
// record is one that resolved at least once. An optional field
@@ -3027,6 +3149,15 @@ pub async fn submit_answer(
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
if let Some((own_bucket, username)) = &owner {
let initial = state
.aggregates
.load()
.get(own_bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
}
// Arrival is a state too: what the schema says the first
// state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
@@ -3159,6 +3290,21 @@ mod responsible_tests {
mod picture_tests {
use super::*;
#[test]
fn an_upload_refusal_says_which_it_was() {
assert_eq!(upload_refusal(Some(413)), "upload_too_large");
assert_eq!(upload_refusal(Some(503)), "upload_unavailable");
assert_eq!(upload_refusal(Some(403)), "upload_forbidden");
assert_eq!(upload_refusal(Some(502)), "upload_failed");
assert_eq!(upload_refusal(None), "upload_failed");
for lang in ["en", "no"] {
for status in [Some(413), Some(503), Some(403), None] {
let key = upload_refusal(status);
assert_ne!(crate::i18n::t(lang, key), key, "{lang} has words for {key}");
}
}
}
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
+64
View File
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
/// Markdown that a person wrote into a record - a bio on a card - as
/// opposed to the site's own content. Paragraphs, line breaks as they
/// were typed, lists, emphasis and links; no images (a picture has its
/// own field, and an image in text is a way to load something from
/// elsewhere into every visitor's page), no raw HTML, headings as plain
/// paragraphs. Links go to https, mailto or tel only, and say they are
/// the writer's, not the site's (`rel="nofollow ugc"`).
pub fn render_written_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
let mut in_dropped_link = 0usize;
let mut in_image = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Image { .. }) => {
in_image += 1;
None
}
Event::End(TagEnd::Image) => {
in_image = in_image.saturating_sub(1);
None
}
_ if in_image > 0 => None,
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
// What someone typed on a new line is on a new line.
Event::SoftBreak => Some(Event::HardBreak),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
{
in_dropped_link += 1;
None
}
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
in_dropped_link -= 1;
None
}
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
}
#[cfg(test)]
mod written_tests {
use super::render_written_markdown as md;
#[test]
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
// Everything that would put something else in the page is gone.
assert_eq!(md("<script>alert(1)</script>"), "");
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
assert_eq!(md("before ![pixel](https://tracker.example/p.gif) after"), "<p>before after</p>");
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
}
}
+16
View File
@@ -343,6 +343,14 @@ pub struct Alternative {
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled: bool,
/// Each signed-in person keeps one record here: their own, which
/// sending again replaces. The form opens with what their record
/// says, a file left empty keeps the one already there, and the
/// older record leaves the list. For what a person says about
/// themselves - a card on the front page, a profile. Needs
/// `record_as`, and someone signed in.
#[serde(default)]
pub one_per_person: bool,
#[serde(default)]
pub consequence: Vec<String>,
#[serde(default)]
@@ -759,6 +767,14 @@ pub struct SiteConfig {
/// intrinsic pixel height.
#[serde(default)]
pub wordmark_height: Option<f32>,
/// Draw an SVG wordmark in the page's own ink instead of its own
/// colours: the file becomes a mask over the text colour, so it
/// follows the page's light and dark (and any theme a site adds),
/// where an `<img>` only ever knows the system's. Opacity in the
/// file still shows, so a lighter line stays lighter. For a
/// one-colour mark; a mark with a colour of its own keeps `false`.
#[serde(default)]
pub wordmark_ink: bool,
/// BCP 47-ish language code for the portal's own chrome strings
/// ("Asked by", the nav lead...) and the html lang attribute.
/// `None` means "en"; unknown codes fall back to English per key.
+7
View File
@@ -512,6 +512,13 @@ pub fn validate_questions(
}
for question in questions.values() {
for alternative in &question.alternatives {
if alternative.one_per_person && alternative.record_as.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
question.id,
alternative.name
);
}
if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket)
|| aggregates
+64
View File
@@ -37,6 +37,55 @@ pub async fn run(state: AppState) {
}
}
/// Records leaving their list: once a record has been in a state with
/// `leaves_after` for that long, it is taken out of the bucket. Not a
/// move - nothing is decided, no mail goes - and the event log keeps
/// the record. Every minute, over the buckets that use it and no
/// others.
pub async fn run_leaving(state: AppState) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
if let Err(e) = sweep_leaving(&state).await {
tracing::warn!(error = %e, "leaving sweep failed");
}
}
}
/// Whether a record has stayed out its time in the state it is in.
pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> bool {
schema
.leaves_for_state
.get(&answer.state)
.is_some_and(|leaves| now_ms >= waiting_since(answer) + leaves.millis())
}
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let aggregates = state.aggregates.load();
for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) {
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { continue };
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => continue,
}
};
for id in keys {
let Ok(Some(bytes)) = store.get(&id).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if has_left(schema, &answer, now_ms) {
match store.delete(&id).await {
Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"),
Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"),
}
}
}
}
Ok(())
}
/// When a record entered the state it is in now. A record nobody has
/// moved has been waiting since it was sent.
fn waiting_since(answer: &Answer) -> i64 {
@@ -150,6 +199,21 @@ aggregates:
}
}
#[test]
fn a_record_leaves_its_list_when_its_time_in_the_state_is_up() {
let s = schema(
"aggregates:\n - bucket: people\n initial: open\n states:\n open: { event: shown }\n gone: { event: gone, leaves_after: { minutes: 10 } }\n transitions:\n open: [gone]\n",
);
let t = 1_000_000_000_000;
let mut answer = waiting("gone", t - 60 * 60 * 1000);
answer.decided_ms = Some(t);
assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending");
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
// A state without it keeps its records.
assert!(!has_left(&s, &waiting("open", 0), t));
assert!(crate::aggregates::parse_aggregates_yaml("aggregates:\n - bucket: p\n initial: open\n states:\n open: { event: o, leaves_after: {} }\n transitions: {}\n").is_err());
}
#[test]
fn a_deadline_comes_due_the_day_it_says_and_not_before() {
let s = schema(HEARINGS);
+18
View File
@@ -24,6 +24,12 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
("en", "upload_forbidden") => "Files cannot be sent from here by you, so nothing was sent. Are you signed in?",
("en", "current_file") => "Now:",
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
("en", "done") => "Done.",
@@ -54,6 +60,12 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
("no", "upload_forbidden") => "Du kan ikke sende filer herfra, så ingenting ble sendt. Er du logget inn?",
("no", "current_file") => "Nå:",
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.",
@@ -102,6 +114,12 @@ mod tests {
"sign_in",
"sign_out",
"nothing_here_yet",
"upload_failed",
"upload_too_large",
"upload_unavailable",
"upload_forbidden",
"current_file",
"keep_file",
"nothing_here",
"back_to_start",
"done",
+5 -3
View File
@@ -180,11 +180,13 @@ fn check_needs(
}
}
if failed == 0 {
let planned = file.needs.iter().filter(|n| n.planned).count();
let planned = file.needs.iter().filter(|n| n.planned && !n.paused).count();
let paused = file.needs.iter().filter(|n| n.paused).count();
println!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
file.needs.len() - planned,
"OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
file.needs.len() - planned - paused,
planned,
paused,
file.personas.len()
);
}
+9 -5
View File
@@ -78,6 +78,10 @@ pub struct Need {
/// as a warning, so the gap stays visible without failing CI.
#[serde(default)]
pub planned: bool,
/// Open before and closed for now (full up, out of season): its
/// card is on the page with no form. Reports like `planned`.
#[serde(default)]
pub paused: bool,
/// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model.
@@ -305,11 +309,11 @@ pub fn check(
let mut report = |level: Level, message: String| {
findings.push(Finding {
need: need.id.clone(),
level: if need.planned { Level::Warn } else { level },
message: if need.planned && level == Level::Fail {
format!("planned, not met yet: {message}")
} else {
message
level: if need.planned || need.paused { Level::Warn } else { level },
message: match (need.paused, need.planned, level == Level::Fail) {
(true, _, true) => format!("paused, not open now: {message}"),
(false, true, true) => format!("planned, not met yet: {message}"),
_ => message,
},
})
};
+1
View File
@@ -132,6 +132,7 @@ async fn serve() -> anyhow::Result<()> {
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone()));
tokio::spawn(portal::deadlines::run_leaving(state.clone()));
// The accounts the content mandates: the first person in, and
// everyone a page names as responsible, at start and whenever the
// content changes (seed.rs).
+177 -27
View File
@@ -425,6 +425,27 @@ pub struct PlanNeed {
/// picture.
#[serde(default)]
pub presents: bool,
/// Not taking new cases for now: full up, a season closed. The
/// card stays where it is, saying what the business does and that
/// it is paused, with no form to fill in and a button that does not
/// press; the server refuses a submission. Remove the line to open
/// again - the desk, the stages and every case under way are
/// untouched either way.
#[serde(default)]
pub paused: bool,
/// Each person in the group that enters it keeps one record: their
/// own card, which they open filled in and send again to change.
/// For what people say about themselves - who they are on the
/// front page. Needs `enters_as`, so there is someone signed in to
/// own it.
#[serde(default)]
pub one_per_person: bool,
/// How long a record stays in the lists once it reaches a stage,
/// when not for ever: `{ sluttet: { minutes: 10 } }` takes a card
/// off the desk's list ten minutes after it came off the front
/// page, instead of leaving it there greyed out.
#[serde(default)]
pub leaves_after: BTreeMap<String, crate::aggregates::LeavesSpec>,
pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
@@ -748,6 +769,15 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
}
}
if let Some(bad) = need.leaves_after.keys().find(|st| !need.stages.contains(st)) {
bail!(at(&format!("leaves_after names {bad:?}, which is not one of its stages")));
}
if need.leaves_after.values().any(|l| l.millis() == 0) {
bail!(at("leaves_after needs some days, hours or minutes"));
}
if need.one_per_person && need.enters_as.is_none() {
bail!(at("one_per_person keeps a card for each signed-in person, and the need has no enters_as to sign them in"));
}
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
}
@@ -1082,6 +1112,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"button": "Send",
"encouragement": "",
"icon": "",
// Said after the description, on a paused need's card only.
"paused": if need.paused { loc(lang, "We are not taking new ones right now, and open again as soon as we can.", "Akkurat nå tar vi ikke imot nye; vi åpner igjen så snart vi kan.") } else { String::new() },
// Three sections of context the plan already knows: what
// the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are
@@ -1145,21 +1177,31 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
}),
);
}
if has_who_page(plan) {
words.insert(
who_page_path(plan),
json!({
"question": loc(lang, "Who are we?", "Hvem er vi?"),
"description": "",
"presents": presenting_buckets(plan).into_iter().map(|b| (b.to_string(), json!({
"heading": humanize(b), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
}))).collect::<Map<String, Value>>(),
"values": {
"heading": loc(lang, "What we stand for", "Det vi står for"),
"description": "",
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" }))).collect::<Map<String, Value>>(),
},
}),
);
}
words.insert(
"questions/index.yaml".to_string(),
json!({
"question": "What brings you here?",
"description": plan.about,
"opener": { "heading": "What we do", "description": "", "features": does },
"who": {
"heading": loc(lang, "Who we are", "Hvem vi er"),
"description": "",
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" })))
.chain(public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(bucket, _)| (format!("people-{bucket}"), json!({
"heading": humanize(bucket), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."), "icon": "",
}))))
.collect::<Map<String, Value>>(),
},
// The way to the page about who the business is.
"who": { "heading": loc(lang, "Who we are", "Hvem vi er"), "description": "", "button": loc(lang, "Meet us", "Bli kjent med oss") },
// Named for what it shows, never an invitation to look.
"shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping },
// A door's feature says what is behind it, from the needs
@@ -1957,15 +1999,32 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if !s(&w["who_description"]).trim().is_empty() {
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
}
features.push(feature);
// A paused need asks for nothing: a form nobody can send has
// already wasted the time it took to fill in.
if !need.paused {
features.push(feature);
}
let description = if need.paused {
text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string())
} else {
text(&w["description"])
};
let mut alt = json!({
"name": text(&w["heading"]),
"description": text(&w["description"]),
"description": description,
"action": action,
"consequence": button(&w["button"]),
"record_as": need.lands_in,
"features": features,
});
if need.one_per_person {
alt["one_per_person"] = json!(true);
}
// Paused, it records nothing and says where nothing goes.
if need.paused {
alt["disabled"] = json!(true);
alt.as_object_mut().map(|a| a.remove("record_as"));
}
if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = button(&w["encouragement"]);
}
@@ -2022,6 +2081,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if let Some(d) = need.deadlines.get(st) {
state["after"] = json!({ "days": d.days, "to": d.to });
}
if let Some(leaves) = need.leaves_after.get(st) {
state["leaves_after"] = json!(leaves);
}
(st.clone(), state)
}).collect::<Map<_, _>>(),
"transitions": machine(need),
@@ -2063,20 +2125,14 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
.collect();
// A public list is shown, not pointed at: its newest few records
// inline, and a link to the whole list.
// Who the business is: its values in its own words, and every
// record of what it presents - the people - shown, not listed.
let mut who: Vec<Value> = plan.values.iter().map(|v| card(&front["who"]["items"][&v.id])).collect();
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
let w = &front["who"]["items"][&format!("people-{bucket}")];
let mut f = card(w);
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), None, "");
who.push(f);
}
if !who.is_empty() {
// Who the business is has a page of its own (see `who_page`);
// the front door only leads there.
if has_who_page(plan) {
alternatives.push(json!({
"name": text(&front["who"]["heading"]),
"description": text(&front["who"]["description"]),
"features": who,
"action": format!("/{}", who_page_id(plan)),
"consequence": button(&front["who"]["button"]),
}));
}
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
@@ -2134,6 +2190,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
front_page["slideshow"] = slideshow.clone();
}
files.insert("questions/index.yaml".into(), front_page);
if has_who_page(plan) {
files.insert(who_page_path(plan), who_page(plan, words));
}
// A door's page: a nav page (index.yaml), public, holding the
// forms of the needs behind it.
@@ -2452,6 +2511,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
});
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
if n.paused { need["paused"] = json!(true); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
if !others.is_empty() { need["also_moved_by"] = json!(others); }
need
@@ -2466,6 +2526,74 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
const DESK_PURPOSE: &str = "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons.";
/// The page about who the business is: a question with nothing to do
/// on it, only things to read. One card per need that `presents` (the
/// people, the work), each showing every record as a card, and one for
/// the business's own statements (`values`), carried word for word.
fn who_page(plan: &Plan, words: &BTreeMap<String, Value>) -> Value {
let w = &words[&who_page_path(plan)];
let mut alternatives: Vec<Value> = Vec::new();
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
let pw = &w["presents"][bucket];
alternatives.push(json!({
"name": text(&pw["heading"]),
"description": text(&pw["description"]),
"features": [{ "name": "", "description": "", "resource": public_resource(bucket, &needs, s(&pw["empty"]), None, "") }],
}));
}
if !plan.values.is_empty() {
let vw = &w["values"];
alternatives.push(json!({
"name": text(&vw["heading"]),
"description": text(&vw["description"]),
"features": plan.values.iter().map(|v| {
let iw = &vw["items"][&v.id];
let mut f = json!({ "name": text(&iw["heading"]), "description": text(&iw["description"]) });
if let Some(icon) = icon_name(s(&iw["icon"])) {
f["icon"] = json!(icon);
}
f
}).collect::<Vec<_>>(),
}));
}
json!({
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": { "name": plan.contact.name, "contact": plan.contact.email },
"alternatives": alternatives,
})
}
fn presenting_buckets(plan: &Plan) -> Vec<&str> {
public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(b, _)| b).collect()
}
fn has_who_page(plan: &Plan) -> bool {
!plan.values.is_empty() || !presenting_buckets(plan).is_empty()
}
fn who_page_id(plan: &Plan) -> String {
loc(&plan.language, "who", "hvem")
}
fn who_page_path(plan: &Plan) -> String {
format!("questions/{}.yaml", who_page_id(plan))
}
/// The needs no persona tries, paused ones aside. A need nobody plays
/// is one whose page is only ever read for the first time by the
/// person it is for: the forms a business fills in from the inside
/// are the easy ones to forget, and where "is this my own card?" goes
/// unasked.
pub fn untried_needs(plan: &Plan) -> Vec<&str> {
plan.needs
.iter()
.filter(|n| !n.paused)
.filter(|n| !plan.personas.iter().any(|p| p.get("need").and_then(|v| v.as_str()) == Some(n.id.as_str())))
.map(|n| n.id.as_str())
.collect()
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
let voice = match tone {
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
@@ -2490,20 +2618,33 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
if !own.is_empty() {
desk_purpose.push_str(&format!(" On this page, the list(s) {} show the signed-in person ONLY the cases they sent in themselves: they are following their own case, not working through other people's, so head those lists as theirs ('Your cases') and say nothing of a queue.", own.iter().map(|b| format!("`{b}`")).collect::<Vec<_>>().join(", ")));
}
// A form a role fills in from inside: its page still asks a
// question, the one the form answers, and says where the answer
// goes - a heading like "Your card on the front page" names a
// place, and the colleague reading it does not know what they are
// being asked.
let own_form = plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}.yaml", n.id)));
let mut inside_purpose = "a page only one role inside the business can open, holding one form. `question` is a real question, the one the form answers, asked of the colleague in their own terms (\"What has been delivered?\"), never a label or the name of a place; where their answer ends up - who sees it, on which page - belongs in `page_description`.".to_string();
if own_form.is_some_and(|n| n.one_per_person) {
inside_purpose.push_str(" This form is the colleague's own record, one each: it opens filled in with what they have, and saving replaces it, so ask about them (\"How do you present yourself?\"), say where it shows, and make the button save.");
}
// A form a role enters from inside is sent by a colleague at work,
// not by a visitor waiting on an answer.
let sent_purpose = match plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}-sent.yaml", n.id))) {
Some(n) => format!("a page shown right after someone in the group `{}` has sent a form from inside the business. The reader is that colleague, at work, not a visitor: nobody comes back to them. Say what has now happened to what they entered, and who it reaches.", n.enters_as.as_deref().unwrap_or_default()),
None => "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next.".to_string(),
};
let purpose = if path == "site.yaml" {
let who_purpose = "the page about who the business is, with nothing to do on it, only things to read. `question` asks it as a question (\"Who are we?\"). Under `presents`, each entry heads one list shown as cards with a picture each - the people who work there, the work they have done - loosely, as a person would introduce them. `values` heads the business's own statements, which are its facts: head them, never add to them.".to_string();
let purpose = if path == who_page_path(plan) {
who_purpose.as_str()
} else if path == "site.yaml" {
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
} else if path == "aggregates.yaml" {
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation."
} else if path.contains("/decide/") {
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
} else if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. `who`, when there is one, is who the business is: its heading asks or names that plainly ('Who we are'), each item under it heads one of the business's own statements (values, experience) or the list of its people, and those statements are the business's facts - head them, never add to them. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. `who`, when there is one, is a card that leads to the page about who the business is: its heading asks that plainly, and `button` goes there. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
} else if path.starts_with("questions/status/") {
status_purpose.as_str()
} else if plan.doors.iter().any(|d| door_path(d) == path) {
@@ -2515,7 +2656,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
sent_purpose.as_str()
} else {
"a page only one role inside the business can open, holding one form."
inside_purpose.as_str()
};
let mut context: Vec<String> = plan
.doors
@@ -2525,7 +2666,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
context.extend(
plan.needs
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
.map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, match (n.paused, n.one_per_person) { (true, _) => " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)", (_, true) => " (ONE EACH: every signed-in person keeps one record here, their own; the form opens filled in with it and sending saves the change, so it speaks to them about their own and its button saves)", _ => "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
);
format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
@@ -2636,6 +2777,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
// The words as used, so the next rebuild can start from them.
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
println!("content repo written to {}", out.display());
for need in untried_needs(&plan) {
eprintln!("note: no persona tries need {need:?}; nobody will find out whether its page reads right until a person does");
}
if let Some(lint) = value("--lint") {
let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?;
@@ -2682,7 +2826,7 @@ mod tests {
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false,
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: false, leaves_after: BTreeMap::new(),
collects: vec![], max_fields: 3,
}
}
@@ -3079,6 +3223,12 @@ mod tests {
assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select"));
}
#[test]
fn a_need_no_persona_tries_is_named() {
let plan: Plan = serde_yaml::from_str("business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: ask\n who: a customer\n wants: an answer\n lands_in: asks\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: card\n who: one of us\n wants: to be seen\n enters_as: desk\n lands_in: cards\n handled_by: desk\n stages: [open, gone]\n done_when: [gone]\n max_fields: 1\n collects:\n - { name: name }\n - id: later\n who: someone\n wants: something\n paused: true\n lands_in: later\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 1\n collects:\n - { name: email, type: email }\npersonas:\n - { id: p, need: ask, text: I need an answer }\n").unwrap();
assert_eq!(untried_needs(&plan), vec!["card"], "the inside need is untried; the paused one is not asked for");
}
#[test]
fn a_public_watcher_gets_a_read_only_page_without_contact_details() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n";
+25 -7
View File
@@ -184,13 +184,31 @@ rest. Each is one line in the plan, and the builder does the wiring:
- **Carry a text word for word.** `documents` puts bylaws or terms on
a page of their own, one card per section, from a markdown file
beside the plan. Nothing rewords them.
- **Say who you are.** `values` are the business's own statements -
what it stands on, the experience behind it - carried word for word
into a "who we are" card on the front door. A need with `presents:
true` and `watched_by: [public]` puts its records in that card, all
of them, as cards: the people who work there, entered from inside
(`enters_as`), each with a `picture` field that shows as their
portrait.
- **Say who you are.** A need with `presents: true` and
`watched_by: [public]` (the people who work there, the work they
have done, each with a `picture` field) and `values` (the business's
own statements, carried word for word) make a page of their own,
"who are we?", with nothing to do on it: one card per presented list
and one for the values. The front door leads there.
- **Give every need someone to play it.** A persona per need,
including the ones a business fills in from the inside (a delivery,
a card on the front page): a page nobody plays is first read by the
person it is for. `portal build` names the needs no persona tries.
- **Let people keep their own.** `one_per_person: true` on a need
entered from inside gives each signed-in person one record there:
their own, which the form opens with and which sending again
replaces - a card on the front page they edit themselves. A picture
left unchosen stays as it was.
- **Let finished things go.** `leaves_after: { <stage>: { minutes: 10 } }`
takes a record out of every list that long after it reaches that
stage (days, hours and minutes add up). A card taken off the front
page need not stand greyed out on the desk for ever; the history
keeps it.
- **Pause what you cannot take on.** `paused: true` on a need keeps
its card on the page, saying what the business does and that it is
not taking new ones right now, with no form and a button that does
not press. Take the line out to open again; the desk and every case
under way are untouched.
- **Show the work.** `slideshow` on the plan is portal's own block,
carried onto the front door as written: a strip of pictures under
the question, each with its caption.
+1 -1
View File
@@ -15,7 +15,7 @@ use serde::Serialize;
use crate::server::AppState;
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
pub const MAX_UPLOAD_BYTES: usize = crate::app::MAX_UPLOAD_BYTES;
#[derive(Clone)]
pub struct Garage {
+57 -4
View File
@@ -244,6 +244,20 @@ main.not-found {
display: block;
}
/* A wordmark in the page's ink (site.yaml `wordmark_ink`): the file is a
mask over the text colour. The invisible image inside gives the span
the file's own size. */
.wordmark-ink-mark {
display: block;
background: var(--ink);
-webkit-mask: var(--wordmark-src) center / contain no-repeat;
mask: var(--wordmark-src) center / contain no-repeat;
}
.wordmark-ink-mark img {
visibility: hidden;
}
.hero-copy h1 {
font-size: 2.1rem;
}
@@ -1300,14 +1314,36 @@ textarea:focus {
font-size: 1.05rem;
}
.answer-card-text p {
margin: 0;
.card-field {
font-size: 0.92rem;
color: var(--ink-dim);
white-space: pre-line;
}
.answer-card-text p:first-of-type {
.card-field > :first-child {
margin-top: 0;
}
.card-field > :last-child {
margin-bottom: 0;
}
.card-field p,
.card-field ul,
.card-field ol {
margin: 0 0 0.5em;
}
.card-field ul,
.card-field ol {
padding-left: 1.2em;
}
.card-field a {
color: var(--accent);
}
/* The field right under the name - a role - reads as part of the name. */
.answer-card-text .card-field:first-of-type {
color: var(--ink);
font-weight: 600;
}
@@ -1325,6 +1361,23 @@ textarea:focus {
object-fit: cover;
}
/* Why a send was refused, under its button. */
.alt-error {
margin: 0.6em 0 0;
color: var(--accent);
font-size: 0.92em;
}
/* The file already on a card of one's own, above the picker. */
.field-current {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A stored file on a record: its name, and it opens. */
.answer-file {
color: var(--accent);
+29
View File
@@ -248,3 +248,32 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
let groups = memberships::on_signed_in(&state, &kari).await;
assert!(groups.contains(&styret.to_string()));
}
#[tokio::test]
async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
use portal::answers::{keep_newest_own, own_records, OWNER_FIELD};
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
let bucket = format!("own_{}", chrono::Utc::now().timestamp_millis());
let schemas = std::collections::HashMap::new();
let now = chrono::Utc::now().timestamp_millis();
let card = |name: &str, by: &str| json!({"name": name, OWNER_FIELD: by});
// Kari's first card, then two sends of her second pressed at once,
// and Ola's card beside them.
store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
// Both racing sends settle after storing; both must keep the same one.
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
let _ = (a, b);
let kari = own_records(&js, &bucket, "kari").await;
assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), vec!["k3"], "one card, the newest");
assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
}