Pages move the way their addresses do; picture cards; who we are #12

Merged
bl merged 1 commits from people-and-motion into main 2026-09-30 17:33:07 +00:00
10 changed files with 1008 additions and 451 deletions
Showing only changes of commit 309e30f36d - Show all commits
+6
View File
@@ -4,6 +4,12 @@ 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
- Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before.
- A record that carries a picture, in a list with no buttons, is shown as a card: the picture, the first field as the heading, the rest under it, with no field names, state or time. Cards are shown in the order they were put up. On a desk, the same picture is a thumbnail.
- Trainer: `values` in a plan and `presents: true` on a need make a "who we are" card on the front door. The business's own statements are carried word for word, beside every record the need presents: the people who work there, entered from inside, each with a `picture` field (a file that only takes JPEG, PNG or WebP) that shows as their portrait. A public list keeps the form's field order, so the first field heads each row.
## 0.6.2 (2026-09-30)
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
+10
View File
@@ -267,6 +267,16 @@ whatever `public` says.
records carrying the signed-in person's own address, read from the
session. A file on such a record is theirs alone to open.
A record that carries a picture (a `type: file` field holding a JPEG,
PNG, WebP, AVIF or GIF) is shown rather than read when the list has no
`transitions`: a card with the picture on top, the first field as its
heading, and the other fields under it, with no field names, state or
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.
`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
was withdrawn — while the desk working the same bucket is a different
+42 -9
View File
@@ -38,6 +38,17 @@ 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.
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.
needs:
- id: tilstand
who: en eier eller forvalter som må vite hvilken forfatning bygget er i, for en kommune, et borettslag, en bedrift eller seg selv
@@ -132,6 +143,28 @@ needs:
- { name: hva }
- { name: dokument, type: file }
# Menneskene: kontoret legger inn hver ansatt med navn, rolle,
# erfaring og portrett, og forsiden viser dem alle.
- id: ansatt
who: kontoret, når noen begynner hos oss eller skal ha nytt portrett
wants: at den som vurderer oss ser hvem som gjør jobben
enters_as: kontor
lands_in: ansatte
handled_by: kontor
watched_by: [public]
presents: true
public_states: [open]
stages: [open, sluttet]
stages_text: { open: "vises på forsiden", sluttet: "sluttet" }
done_when: [sluttet]
success: [sluttet]
max_fields: 4
collects:
- { name: name }
- { name: rolle }
- { name: erfaring, type: textarea }
- { name: portrett, type: picture }
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." }
@@ -187,19 +220,19 @@ slideshow:
- image: images/bildeserie/11.webp
width: 1050
height: 1400
caption: Solid veggprofil tilpasset lyd og brann-krav
caption: Solid veggprofil tilpasset lyd- og brannkrav
- image: images/bildeserie/12.webp
width: 1867
height: 1400
caption: Nye vinduer med tradisjonelt-utseende «Restover» glass som avslører utsikten fra Munkeramme på Hvitsten
caption: Nye vinduer med tradisjonelt utseende «Restover»-glass som avslører utsikten fra Munkeramme på Hvitsten
- image: images/bildeserie/13.webp
width: 2571
height: 1400
caption: Kjærneved til varanda og inngangspartiet til Muncheramme
caption: Kjerneved til verandaen og inngangspartiet til Muncheramme
- image: images/bildeserie/14.webp
width: 1050
height: 1400
caption: Drenering og riktige rammer før kunsgressbanen legges mesterfullt ned helt uten gummikuler
caption: Drenering og riktige rammer før kunstgressbanen legges mesterfullt ned helt uten gummikuler
- image: images/bildeserie/15.webp
width: 2098
height: 1400
@@ -211,11 +244,11 @@ slideshow:
- image: images/bildeserie/17.webp
width: 1050
height: 1400
caption: Etanblering av ny byggekran
caption: Etablering av ny byggekran
- image: images/bildeserie/18.webp
width: 1050
height: 1400
caption: Armerignskontroll før støping
caption: Armeringskontroll før støping
- image: images/bildeserie/19.webp
width: 1867
height: 1400
@@ -223,7 +256,7 @@ slideshow:
- image: images/bildeserie/20.webp
width: 1867
height: 1400
caption: Her heises Moelvens modulskole i vestby på plass. Den blir stående i 10 år.
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i 10 år.
- image: images/bildeserie/21.webp
width: 1867
height: 1400
@@ -235,7 +268,7 @@ slideshow:
- image: images/bildeserie/23.webp
width: 2095
height: 1400
caption: Nytt kunstgress med varme på Fredikstad stasion.
caption: Nytt kunstgress med varme på Fredrikstad stasjon.
- image: images/bildeserie/24.webp
width: 1050
height: 1400
@@ -243,4 +276,4 @@ slideshow:
- image: images/bildeserie/25.webp
width: 1487
height: 1400
caption: pH-test av borreprøver i mur fra forskjellige dybder
caption: pH-test av borprøver i mur fra forskjellige dybder
+514 -430
View File
@@ -1,441 +1,525 @@
{
"questions/index.yaml": {
"question": "Hvordan inkluderer vi ditt bygg i framtiden?",
"description": "Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt** og godkjent **søknad** for ditt bygg.",
"opener": {
"heading": "Dette gjør vi",
"description": "For kommuner, borettslag, bedrifter og private.",
"features": {
"tilstand": {
"heading": "Tilstand, målt og dokumentert",
"description": "Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med befaring, materialprøver og termofotografering. Skal bygget rives, leverer vi miljøsaneringsrapport; energibruk og besparelser hører med i vurderingen. Vi har samarbeidet med [Drammen Kommune](https://drammen.kommune.no), [Nordre Follo Kommune](https://nordrefollo.kommune.no) og [Bølerskogens Borettslag](https://bolerskogenborettslag.no/).",
"icon": "clipboard-check"
"questions/index.yaml": {
"question": "Hvordan inkluderer vi ditt bygg i framtiden?",
"description": "Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt** og godkjent **søknad** for ditt bygg.",
"opener": {
"heading": "Dette gjør vi",
"description": "For kommuner, borettslag, bedrifter og private.",
"features": {
"tilstand": {
"heading": "Tilstand, målt og dokumentert",
"description": "Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med befaring, materialprøver og termofotografering. Skal bygget rives, leverer vi miljøsaneringsrapport; energibruk og besparelser hører med i vurderingen. Vi har samarbeidet med [Drammen Kommune](https://drammen.kommune.no), [Nordre Follo Kommune](https://nordrefollo.kommune.no) og [Bølerskogens Borettslag](https://bolerskogenborettslag.no/).",
"icon": "clipboard-check"
},
"soknad": {
"heading": "Prosjekt og søknad, med ansvarsrett",
"description": "Vi lager arbeidsbeskrivelse opp mot [NS 3420](https://standard.no/fagomrader/ns-3420-/) og fremdriftsplan, og utarbeider byggesøknaden. Vi har sentral [godkjenning](https://sgregister.dibk.no/enterprises/976015711) innen flere fagområder og kan stå med det offentligrettslige ansvaret. Samarbeidspartner: [Westra Engineering AS](https://www.westra-engineering.no/).",
"icon": "file-check"
}
}
},
"soknad": {
"heading": "Prosjekt og søknad, med ansvarsrett",
"description": "Vi lager arbeidsbeskrivelse opp mot [NS 3420](https://standard.no/fagomrader/ns-3420-/) og fremdriftsplan, og utarbeider byggesøknaden. Vi har sentral [godkjenning](https://sgregister.dibk.no/enterprises/976015711) innen flere fagområder og kan stå med det offentligrettslige ansvaret. Samarbeidspartner: [Westra Engineering AS](https://www.westra-engineering.no/).",
"icon": "file-check"
"needs": {
"tilstand": {
"heading": "Få kartlagt byggets tilstand",
"description": "Vi utarbeider tilstandsrapporter som representerer bygget med hensyn til fremtidige mål.",
"button": "Kom i gang",
"encouragement": "Mindre stillstand etter dette.",
"icon": "clipboard-list",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om bygget",
"form_description": "Det vi trenger for å komme på befaring. Har du tegninger, bilder eller video, tar vi gjerne imot dem.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"eiendom": "Hva slags eiendom er det?",
"mangler": "Hvilke mangler ønskes adressert?",
"tegninger": "Bilder, tegninger, skisser eller video"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"mangler": "Forfall, skader eller annet…"
}
},
"prosjekt": {
"heading": "Prosjektér dine planer med oss",
"description": "Ivareta eksisterende ressurser for et velfungerende og helhetlig bygg.",
"button": "Start samarbeidet",
"encouragement": "Gi det beste grunnlaget for utførelse.",
"icon": "ruler",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om planene",
"form_description": "Beskriv hva du ønsker for bygget. Har du noe digitalt å vise oss, blir det lettere å vite hvor vi skal starte.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"planer": "Hva er dine planer for bygget?",
"oppstart": "Ønsket start",
"skisser": "Bilder, CAD-filer og skisser"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"planer": "Ønsker for bygget…"
}
},
"soknad": {
"heading": "Ta det første spadetaket med en godkjent søknad",
"description": "Er bygget ferdig prosjektert, kan vi utarbeide en byggesøknad og utførelsesbeskrivelse for offentlig godkjenning.",
"button": "Send henvendelse",
"encouragement": "Nesten som å ha gravemaskin!",
"icon": "file-text",
"road_title": "Slik går det videre",
"road_description": "Vi går gjennom det du har, avtaler befaring eller forhåndskonferanse, og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger søknaden og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om tiltaket",
"form_description": "Hva skal bygges, og hvor. Avhengig av størrelsen kan vi holde forhåndskonferanse med kommunen først.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"tiltak": "Hva går tiltaket ut på?",
"tegninger": "Tegninger og prosjektering"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
}
}
},
"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"
}
}
}
}
},
"needs": {
"tilstand": {
"heading": "Få kartlagt byggets tilstand",
"description": "Vi utarbeider tilstandsrapporter som representerer bygget med hensyn til fremtidige mål.",
"button": "Kom i gang",
"encouragement": "Mindre stillstand etter dette.",
"icon": "clipboard-list",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om bygget",
"form_description": "Det vi trenger for å komme på befaring. Har du tegninger, bilder eller video, tar vi gjerne imot dem.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"eiendom": "Hva slags eiendom er det?",
"mangler": "Hvilke mangler ønskes adressert?",
"tegninger": "Bilder, tegninger, skisser eller video"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"mangler": "Forfall, skader eller annet…"
}
},
"prosjekt": {
"heading": "Prosjektér dine planer med oss",
"description": "Ivareta eksisterende ressurser for et velfungerende og helhetlig bygg.",
"button": "Start samarbeidet",
"encouragement": "Gi det beste grunnlaget for utførelse.",
"icon": "ruler",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om planene",
"form_description": "Beskriv hva du ønsker for bygget. Har du noe digitalt å vise oss, blir det lettere å vite hvor vi skal starte.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"planer": "Hva er dine planer for bygget?",
"oppstart": "Ønsket start",
"skisser": "Bilder, CAD-filer og skisser"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"planer": "Ønsker for bygget…"
}
},
"soknad": {
"heading": "Ta det første spadetaket med en godkjent søknad",
"description": "Er bygget ferdig prosjektert, kan vi utarbeide en byggesøknad og utførelsesbeskrivelse for offentlig godkjenning.",
"button": "Send henvendelse",
"encouragement": "Nesten som å ha gravemaskin!",
"icon": "file-text",
"road_title": "Slik går det videre",
"road_description": "Vi går gjennom det du har, avtaler befaring eller forhåndskonferanse, og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger søknaden og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om tiltaket",
"form_description": "Hva skal bygges, og hvor. Avhengig av størrelsen kan vi holde forhåndskonferanse med kommunen først.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"tiltak": "Hva går tiltaket ut på?",
"tegninger": "Tegninger og prosjektering"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
}
}
}
},
"questions/received/tilstand.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om tilstandsrapport er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/prosjekt.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om prosjektering er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/soknad.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om byggesøknad er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/decide/tilstand.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på tilstandsrapporten har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/prosjekt.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på prosjekteringen har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/soknad.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på byggesøknaden har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/kontor/index.yaml": {
"question": "Hva ligger på pulten nå?",
"description": "Alle henvendelser og oppdrag, fra første melding til levert dokument.",
"confirm": "Bekreft",
"buckets": {
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Henvendelser om tilstandsrapport, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Henvendelser om prosjektering, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Henvendelser om byggesøknad, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Byggesøknader",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"leveranser": {
"heading": "Leverte dokumenter",
"description": "Rapporter, tegninger og vedtak som er sendt til kunder.",
"moves": {
"open->arkivert": "Arkiver"
},
"list_title": "Leveranser",
"list_note": "Hver rad er ett dokument en kunde har fått. Arkiver når saken er avsluttet."
}
"questions/received/tilstand.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om tilstandsrapport er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"links": {
"leveranse": {
"questions/received/prosjekt.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om prosjektering er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/soknad.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om byggesøknad er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/decide/tilstand.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på tilstandsrapporten har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/prosjekt.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på prosjekteringen har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/soknad.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på byggesøknaden har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/kontor/index.yaml": {
"question": "Hva ligger på pulten nå?",
"description": "Alle henvendelser og oppdrag, fra første melding til levert dokument.",
"confirm": "Bekreft",
"buckets": {
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Henvendelser om tilstandsrapport, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Henvendelser om prosjektering, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Henvendelser om byggesøknad, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Byggesøknader",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"leveranser": {
"heading": "Leverte dokumenter",
"description": "Rapporter, tegninger og vedtak som er sendt til kunder.",
"moves": {
"open->arkivert": "Arkiver"
},
"list_title": "Leveranser",
"list_note": "Hver rad er ett dokument en kunde har fått. Arkiver når saken er avsluttet."
},
"ansatte": {
"heading": "Ansatte på forsiden",
"description": "Menneskene slik besøkende ser dem.",
"moves": {
"open->sluttet": "Tatt av forsiden"
},
"list_title": "Ansatte",
"list_note": "Hver rad er én person på forsiden."
}
},
"links": {
"leveranse": {
"heading": "Lever et dokument til en kunde",
"description": "Rapporten, tegningene eller vedtaket. Kunden får e-post og henter det innlogget.",
"button": "Ny leveranse"
},
"ansatt": {
"heading": "Legg inn en ansatt",
"description": "Navn, rolle, erfaring og portrett, rett ut på forsiden.",
"button": "Åpne"
}
},
"invites": {
"kontor": {
"heading": "Gi en kollega tilgang",
"description": "De får en konto og plass på kontoret, og ser de samme sakene som deg.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"kunder": {
"heading": "Gi en kunde tilgang",
"description": "For en kunde som ikke kom inn gjennom et tilbud. De får en konto og ser sine egne oppdrag.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Sendte invitasjoner",
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"done": "Er inne",
"again": "Send på nytt",
"list_title": "Hvem som er invitert",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"suggestions": {
"heading": "Forslag til endringer på siden",
"description": "Det de som står som ansvarlige for et spørsmål, har foreslått å endre.",
"list_title": "Forslag",
"list_note": "Hver rad er ett forslag, med siden det gjelder.",
"looking": "Vi ser på det",
"changed": "Endringen er gjort",
"declined": "Gjøres ikke"
}
},
"questions/kunder/index.yaml": {
"question": "Hvor står oppdraget ditt?",
"description": "Her følger du dine egne oppdrag hos Klingenberg Bygg og henter dokumentene vi har levert.",
"confirm": "Bekreft",
"buckets": {
"leveranser": {
"heading": "Dokumenter til deg",
"description": "Rapporter, tegninger og vedtak vi har levert. Trykk på filnavnet for å hente dokumentet.",
"moves": {},
"list_title": "Dokumenter",
"list_note": "Hver rad er ett dokument vi har levert til deg."
},
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Dine oppdrag om tilstandsrapport, og hvor langt de har kommet.",
"moves": {},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Dine oppdrag om prosjektering, og hvor langt de har kommet.",
"moves": {},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Dine oppdrag om byggesøknad, og hvor langt de har kommet.",
"moves": {},
"list_title": "Byggesøknader",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
}
}
},
"questions/kontor/leveranse.yaml": {
"heading": "Lever et dokument til en kunde",
"description": "Rapporten, tegningene eller vedtaket. Kunden får e-post og henter det innlogget.",
"button": "Ny leveranse"
}
},
"invites": {
"kontor": {
"heading": "Gi en kollega tilgang",
"description": "De får en konto og plass på kontoret, og ser de samme sakene som deg.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"description": "Last opp rapporten, tegningene eller vedtaket. Kunden får e-post og henter dokumentet innlogget.",
"button": "Lever",
"encouragement": "Kunden får beskjed med en gang.",
"icon": "send",
"road_title": "Hva skjer",
"road_description": "Dokumentet legges på kundens side, og kunden får e-post om at det ligger der.",
"road_icon": "route",
"who_title": "Hvem ser dette",
"who_description": "Kunden med den e-postadressen du oppgir, og kontoret.",
"who_icon": "users",
"form_title": "Leveransen",
"form_description": "Bruk den e-postadressen kunden logger inn med.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
"email": "Kundens e-post",
"hva": "Hva er levert",
"dokument": "Dokument"
},
"hints": {
"email": "kunde@eksempel.no",
"hva": "Tilstandsrapport for Storgata 12",
"dokument": ""
},
"question": "Hvilket dokument er klart til kunden?",
"page_description": "Ferdige rapporter, tegninger og vedtak leveres her."
},
"questions/kontor/leveranse-sent.yaml": {
"question": "Hva skjer nå?",
"description": "Dokumentet er levert.",
"next_heading": "Kunden har fått beskjed",
"next_description": "Kunden har fått e-post og finner dokumentet på sin side. Leveransen ligger i listen på pulten til du arkiverer den."
},
"aggregates.yaml": {
"tilstandsrapporter": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om tilstandsrapport for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på tilstandsrapport for {adresse}",
"body": "Hei {name},\n\nTilbudet på tilstandsrapport for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på tilstandsrapport for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Tilstandsrapport for {adresse} er levert",
"body": "Hei {name},\n\nTilstandsrapport for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"prosjekteringer": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om prosjektering for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på prosjektering for {adresse}",
"body": "Hei {name},\n\nTilbudet på prosjektering for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på prosjektering for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Prosjektering for {adresse} er levert",
"body": "Hei {name},\n\nProsjektering for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"soknader": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om byggesøknad for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på byggesøknad for {adresse}",
"body": "Hei {name},\n\nTilbudet på byggesøknad for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på byggesøknad for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Byggesøknad for {adresse} er levert",
"body": "Hei {name},\n\nByggesøknad for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"leveranser": {
"open": {
"subject": "{hva} ligger klar til deg",
"body": "Hei,\n\n{hva} er levert fra Klingenberg Bygg. Logg inn og hent dokumentet her:\n{site}/kunder\n\nSvar på denne e-posten om noe mangler.\n\nKlingenberg Bygg"
}
}
},
"kunder": {
"heading": "Gi en kunde tilgang",
"description": "For en kunde som ikke kom inn gjennom et tilbud. De får en konto og ser sine egne oppdrag.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
},
"site.yaml": {
"invite": {
"subject": "Din innlogging hos {site_name}",
"body": "Hei {name},\n\nDu har fått en konto hos {site_name}, med plass blant {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}."
}
},
"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.",
"see_all": {
"heading": "Se alle ansatte",
"description": "",
"button": "Åpne"
}
},
"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",
"encouragement": "",
"icon": "user-plus",
"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_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_icon": "users",
"form_title": "Den ansatte",
"form_description": "",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
"name": "Navn",
"rolle": "Rolle",
"erfaring": "Erfaring",
"portrett": "Portrett"
},
"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"
},
"question": "Hvem skal vises på forsiden?",
"page_description": ""
},
"invited": {
"heading": "Sendte invitasjoner",
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"done": "Er inne",
"again": "Send på nytt",
"list_title": "Hvem som er invitert",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"suggestions": {
"heading": "Forslag til endringer på siden",
"description": "Det de som står som ansvarlige for et spørsmål, har foreslått å endre.",
"list_title": "Forslag",
"list_note": "Hver rad er ett forslag, med siden det gjelder.",
"looking": "Vi ser på det",
"changed": "Endringen er gjort",
"declined": "Gjøres ikke"
"questions/status/ansatte/index.yaml": {
"question": "Hvem består Klingenberg Bygg av?",
"description": "Menneskene som går befaringene, skriver rapportene og står for søknadene.",
"heading": "Menneskene",
"list_description": "",
"list_title": "Menneskene",
"list_note": "",
"empty": "Snart presenterer vi oss her.",
"answer": ""
}
},
"questions/kunder/index.yaml": {
"question": "Hvor står oppdraget ditt?",
"description": "Her følger du dine egne oppdrag hos Klingenberg Bygg og henter dokumentene vi har levert.",
"confirm": "Bekreft",
"buckets": {
"leveranser": {
"heading": "Dokumenter til deg",
"description": "Rapporter, tegninger og vedtak vi har levert. Trykk på filnavnet for å hente dokumentet.",
"moves": {},
"list_title": "Dokumenter",
"list_note": "Hver rad er ett dokument vi har levert til deg."
},
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Dine oppdrag om tilstandsrapport, og hvor langt de har kommet.",
"moves": {},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Dine oppdrag om prosjektering, og hvor langt de har kommet.",
"moves": {},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Dine oppdrag om byggesøknad, og hvor langt de har kommet.",
"moves": {},
"list_title": "Byggesøknader",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
}
}
},
"questions/kontor/leveranse.yaml": {
"heading": "Lever et dokument til en kunde",
"description": "Last opp rapporten, tegningene eller vedtaket. Kunden får e-post og henter dokumentet innlogget.",
"button": "Lever",
"encouragement": "Kunden får beskjed med en gang.",
"icon": "send",
"road_title": "Hva skjer",
"road_description": "Dokumentet legges på kundens side, og kunden får e-post om at det ligger der.",
"road_icon": "route",
"who_title": "Hvem ser dette",
"who_description": "Kunden med den e-postadressen du oppgir, og kontoret.",
"who_icon": "users",
"form_title": "Leveransen",
"form_description": "Bruk den e-postadressen kunden logger inn med.",
"labels": {
"email": "Kundens e-post",
"hva": "Hva er levert",
"dokument": "Dokument"
},
"hints": {
"email": "kunde@eksempel.no",
"hva": "Tilstandsrapport for Storgata 12",
"dokument": ""
},
"question": "Hvilket dokument er klart til kunden?",
"page_description": "Ferdige rapporter, tegninger og vedtak leveres her."
},
"questions/kontor/leveranse-sent.yaml": {
"question": "Hva skjer nå?",
"description": "Dokumentet er levert.",
"next_heading": "Kunden har fått beskjed",
"next_description": "Kunden har fått e-post og finner dokumentet på sin side. Leveransen ligger i listen på pulten til du arkiverer den."
},
"aggregates.yaml": {
"tilstandsrapporter": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om tilstandsrapport for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på tilstandsrapport for {adresse}",
"body": "Hei {name},\n\nTilbudet på tilstandsrapport for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på tilstandsrapport for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Tilstandsrapport for {adresse} er levert",
"body": "Hei {name},\n\nTilstandsrapport for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"prosjekteringer": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om prosjektering for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på prosjektering for {adresse}",
"body": "Hei {name},\n\nTilbudet på prosjektering for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på prosjektering for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Prosjektering for {adresse} er levert",
"body": "Hei {name},\n\nProsjektering for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"soknader": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om byggesøknad for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på byggesøknad for {adresse}",
"body": "Hei {name},\n\nTilbudet på byggesøknad for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på byggesøknad for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Byggesøknad for {adresse} er levert",
"body": "Hei {name},\n\nByggesøknad for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"leveranser": {
"open": {
"subject": "{hva} ligger klar til deg",
"body": "Hei,\n\n{hva} er levert fra Klingenberg Bygg. Logg inn og hent dokumentet her:\n{site}/kunder\n\nSvar på denne e-posten om noe mangler.\n\nKlingenberg Bygg"
}
}
},
"site.yaml": {
"invite": {
"subject": "Din innlogging hos {site_name}",
"body": "Hei {name},\n\nDu har fått en konto hos {site_name}, med plass blant {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}."
}
}
}
}
+144
View File
@@ -0,0 +1,144 @@
// Pages move the way their addresses do.
//
// Deeper in the address comes in from the right and going back up
// comes in from the left: /kontor/ansatt sits to the right of /kontor.
// A page at the same depth comes from below, or from above when it
// sorts first, so the index is on top. The cards on a page leave as
// islands of their own and the next page's cards arrive one after
// another.
//
// The router swaps the page after its data has arrived, and in between
// shows nothing at all. So the click is held here, before the router
// sees it: the browser takes its picture of the page as it is, the
// click is handed on, and the transition waits until the new page has
// replaced the old one. Only transforms and opacity are animated, on
// layers the browser composites itself. Without View Transitions, or
// with reduced motion asked for, nothing here does anything and the
// router navigates as it always did.
(() => {
if (!document.startViewTransition) return;
const still = matchMedia("(prefers-reduced-motion: reduce)");
const MAX_CARDS = 8;
const WAIT_MS = 2500;
const handed = new WeakSet();
const segments = (path) => path.split("/").filter(Boolean);
// forward, back, down or up: which way the new page comes from.
function direction(from, to) {
const a = segments(from);
const b = segments(to);
if (b.length > a.length) return "forward";
if (b.length < a.length) return "back";
return to < from ? "up" : "down";
}
// Only the cards on screen become islands: each is a picture the
// browser has to take and hold, and one nobody sees costs the same.
function inView(card) {
const r = card.getBoundingClientRect();
return r.bottom > 0 && r.top < innerHeight;
}
function name(cards, prefix) {
cards.filter(inView).slice(0, MAX_CARDS).forEach((card, i) => {
card.style.viewTransitionName = `${prefix}-${i + 1}`;
});
}
function unname() {
document.querySelectorAll(".alt-card").forEach((card) => {
card.style.viewTransitionName = "";
});
}
// Resolves once the page that was showing has been replaced by the
// one at `path`, or after WAIT_MS whatever happened.
function replaced(old, path) {
return new Promise((resolve) => {
const done = () => {
observer.disconnect();
clearTimeout(timer);
resolve();
};
const check = () => {
if (location.pathname !== path) return;
if (document.querySelector("main.loading")) return;
if (old && old.isConnected) return;
if (!document.querySelector(".alternatives, main.not-found")) return;
done();
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(done, WAIT_MS);
check();
});
}
function move(from, to, handOn) {
const root = document.documentElement;
const old = document.querySelector(".alternatives");
root.dataset.motion = direction(from, to);
name([...document.querySelectorAll(".alt-card")], "pm-out");
const transition = document.startViewTransition(async () => {
unname();
handOn();
await replaced(old, to);
name([...document.querySelectorAll(".alt-card")], "pm-in");
});
transition.finished.finally(() => {
unname();
delete root.dataset.motion;
});
}
document.addEventListener(
"click",
(ev) => {
if (still.matches || handed.has(ev) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = ev.target.closest && ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return;
if ((a.getAttribute("rel") || "").split(/\s+/).includes("external")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin || url.pathname === location.pathname) return;
ev.preventDefault();
ev.stopImmediatePropagation();
move(location.pathname, url.pathname, () => {
const again = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0 });
handed.add(again);
a.dispatchEvent(again);
});
},
true,
);
// Back and forward: the address has already changed when this runs,
// so the router is told a moment later, inside the transition.
let shown = location.pathname;
window.addEventListener(
"popstate",
(ev) => {
const to = location.pathname;
const from = shown;
shown = to;
if (still.matches || handed.has(ev) || from === to) return;
ev.stopImmediatePropagation();
move(from, to, () => {
const again = new PopStateEvent("popstate", { state: ev.state });
handed.add(again);
window.dispatchEvent(again);
});
},
true,
);
// Keep `shown` right after the router's own pushes.
for (const method of ["pushState", "replaceState"]) {
const original = history[method].bind(history);
history[method] = (...args) => {
const result = original(...args);
shown = location.pathname;
return result;
};
}
})();
+89 -2
View File
@@ -57,6 +57,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
// Before the router, so it sees a link's click first.
<script src="/page-motion.js" defer=true></script>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
@@ -1949,12 +1951,16 @@ fn ResourceValue(
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
// Cards are shown in the order they were put up, the way a
// team or a gallery is read; a list of cases, newest first.
let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some());
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
open(&a.state).cmp(&open(&b.state)).then(when)
});
return view! {
<div class="answer-list">
<div class="answer-list" class:answer-cards=cards>
<For
each=move || answers.clone()
key=|a| a.id.clone()
@@ -2232,6 +2238,23 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// 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();
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext))
}
/// A record that carries a picture, in a list nobody works from, is
/// shown rather than read: the picture, then what it says, first field
/// as its heading. A team, a gallery of what has been built.
fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option<String> {
let bucket = bucket?;
answer.responses.as_object()?.iter().find_map(|(key, value)| {
let name = uploaded_filename(value.as_str()?)?;
is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id))
})
}
#[component]
fn AnswerRow(
feature_name: String,
@@ -2260,6 +2283,11 @@ fn AnswerRow(
} else {
available.is_empty()
};
if transitions.is_empty() {
if let Some(picture) = picture_of(&answer, bucket.as_deref()) {
return AnswerCard(answer, picture, link).into_any();
}
}
let responses = answer
.responses
.as_object()
@@ -2310,6 +2338,17 @@ fn AnswerRow(
// A stored file shows as its name, and opens.
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
let href = format!("{base}/{key}");
if is_picture(filename) {
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value" href=href.clone() rel="external">
<img class="answer-thumb" src=href.clone() alt=filename.to_string() loading="lazy" />
</a>
</div>
}
.into_any();
}
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
@@ -2412,6 +2451,36 @@ fn AnswerRow(
</Show>
</article>
}
.into_any()
}
/// A record shown as a card: its picture, its first field as the
/// heading and the others under it, and nothing a reader has no use
/// for - no field names, no state, no time.
#[allow(non_snake_case)]
fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl IntoView {
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
let texts: Vec<String> = answer
.responses
.as_object()
.into_iter()
.flatten()
.filter_map(|(_, v)| v.as_str())
.filter(|v| uploaded_filename(v).is_none() && !v.trim().is_empty())
.map(str::to_string)
.collect();
let heading = texts.first().cloned().unwrap_or_default();
let rest: Vec<String> = texts.into_iter().skip(1).collect();
view! {
<article class="answer-row answer-card">
<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()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
}
}
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
@@ -3085,3 +3154,21 @@ mod responsible_tests {
assert!(!is_responsible(None, &board));
}
}
#[cfg(test)]
mod picture_tests {
use super::*;
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
let answer: Answer = serde_json::from_value(serde_json::json!({
"id": "r1", "question_id": "kontor/ansatt", "alternative": "a", "state": "open", "submitted_ms": 1,
"responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" }
}))
.unwrap();
assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett"));
// Without a bucket there is nowhere to fetch it from.
assert_eq!(picture_of(&answer, None), None);
}
}
+11
View File
@@ -231,6 +231,7 @@ async fn serve() -> anyhow::Result<()> {
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
let app = Router::new()
.route("/auth/login", get(oidc::login))
@@ -284,6 +285,16 @@ async fn serve() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
// Same name every release, so revalidated like /pkg.
.route_service(
"/page-motion.js",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new(page_motion)),
)
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
.leptos_routes_with_context(
+65 -10
View File
@@ -95,6 +95,13 @@ pub struct Plan {
/// from its `wants`.
#[serde(default)]
pub does: Vec<Does>,
/// What the business stands on, in its own words: values, the
/// experience behind it. Each is a feature of the front door's
/// "who we are" card, beside the needs that `present` - the people
/// who work there. Nothing rewords the facts; the words model only
/// heads them.
#[serde(default)]
pub values: Vec<Does>,
/// Things a visitor may look at before asking for anything - the
/// window shopping: a list of what has been built, releases, a
/// live count - each a portal resource block, passed through as
@@ -206,7 +213,7 @@ pub fn sections_of(markdown: &str) -> Vec<Section> {
let mut heading: Option<String> = None;
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
let close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
if let Some(h) = heading.take() {
if !paragraphs.is_empty() {
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
@@ -411,6 +418,13 @@ pub struct PlanNeed {
/// taken down; a register shows what was decided.
#[serde(default)]
pub public_states: Vec<String>,
/// Its records are something the business shows, not cases it
/// works: the people who work there, what it has built. With
/// `watched_by: [public]`, the front door shows every one of them
/// in the "who we are" card, and a `picture` field shows as the
/// picture.
#[serde(default)]
pub presents: bool,
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.
@@ -488,7 +502,7 @@ impl Plan {
}
}
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"];
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file", "picture"];
fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
@@ -629,8 +643,11 @@ impl Plan {
// small application and the firm's large one, and is one
// page either way. A bucket only a role ever writes to has
// its group's desk and nothing to publish.
// Unless it presents: the people are entered from inside
// precisely so that everyone can see them.
if need.watched_by.iter().any(|g| g == PUBLIC)
&& need.enters_as.is_some()
&& !need.presents
&& !self
.needs
.iter()
@@ -731,6 +748,9 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
}
}
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"));
}
if !need.public_states.is_empty() {
if !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by"));
@@ -1113,7 +1133,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
};
let mut shopping: Map<String, Value> =
plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect();
for (bucket, needs) in public_buckets(plan) {
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
shopping.insert(
format!("status-{bucket}"),
json!({
@@ -1131,6 +1151,15 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"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>>(),
},
// 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
@@ -1589,9 +1618,13 @@ const FRONT_DOOR_LATEST: usize = 3;
/// and at most `limit` records when given. Portal filters by `states`
/// while listing, before `jq` runs, so the limit counts shown records.
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value {
let mut shown: BTreeSet<&str> = BTreeSet::new();
for n in needs {
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
// In the order the form asks for them: the first is what a row, or
// a card, is headed by.
let mut shown: Vec<&str> = Vec::new();
for f in needs.iter().flat_map(|n| n.collects.iter()).filter(|f| f.is_public()) {
if !shown.contains(&f.name.as_str()) {
shown.push(f.name.as_str());
}
}
let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default();
// One record per yielded value: portal collects what the filter
@@ -1864,6 +1897,12 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
let parent = plan.parent_of(need);
let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind });
// A picture is a file the browser can show: a phone offers its
// camera roll and sends a JPEG, whatever it keeps them as.
if f.kind == "picture" {
r["type"] = json!("file");
r["accept"] = json!("image/jpeg,image/png,image/webp");
}
if f.optional { r["optional"] = json!(true); }
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
if f.multiple { r["multiple"] = json!(true); }
@@ -2024,7 +2063,23 @@ 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.
for (bucket, needs) in public_buckets(plan) {
// 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() {
alternatives.push(json!({
"name": text(&front["who"]["heading"]),
"description": text(&front["who"]["description"]),
"features": who,
}));
}
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
let mut f = card(w);
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
@@ -2448,7 +2503,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
} 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. 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 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."
} else if path.starts_with("questions/status/") {
status_purpose.as_str()
} else if plan.doors.iter().any(|d| door_path(d) == path) {
@@ -2627,7 +2682,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![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false,
collects: vec![], max_fields: 3,
}
}
@@ -3037,7 +3092,7 @@ mod tests {
let resource = &view["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["public"], true);
assert!(resource.get("requires_group").is_none());
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {what: .what, where: .where}");
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {where: .where, what: .what}");
assert!(view.get("qualifies").is_none());
// The followup page points people at the list.
let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap();
+7
View File
@@ -184,6 +184,13 @@ 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.
- **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.
+120
View File
@@ -1269,6 +1269,62 @@ textarea:focus {
color: var(--accent);
}
/* A read-only list whose records carry a picture: shown, not read. */
.answer-cards {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
align-items: start;
}
.answer-card {
padding: 0;
overflow: hidden;
gap: 0;
}
.answer-picture {
display: block;
width: 100%;
aspect-ratio: 4 / 5;
object-fit: cover;
background: var(--line);
}
.answer-card-text {
padding: 0.9rem 1.1rem 1.1rem;
display: grid;
gap: 0.35rem;
}
.answer-card-heading {
margin: 0;
font-size: 1.05rem;
}
.answer-card-text p {
margin: 0;
font-size: 0.92rem;
color: var(--ink-dim);
white-space: pre-line;
}
.answer-card-text p:first-of-type {
color: var(--ink);
font-weight: 600;
}
.answer-card .answer-link {
margin: 0 1.1rem 1.1rem;
}
/* A picture on a desk's record: small, and it opens. */
.answer-thumb {
display: block;
max-width: 8rem;
max-height: 8rem;
border-radius: 0.4rem;
object-fit: cover;
}
/* A stored file on a record: its name, and it opens. */
.answer-file {
color: var(--accent);
@@ -1520,3 +1576,67 @@ textarea:focus {
.resource-loading {
min-height: 1.4em;
}
/* Pages move the way their addresses do (public/page-motion.js):
deeper comes in from the right, back up from the left, a page at the
same depth from below or above. The cards leave and arrive as
islands of their own, one after another. Transforms and opacity
only. */
@media (prefers-reduced-motion: no-preference) {
:root {
--pm-duration: 340ms;
--pm-ease: cubic-bezier(0.22, 0.8, 0.24, 1);
--pm-step: 45ms;
}
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: var(--pm-duration);
animation-timing-function: var(--pm-ease);
animation-fill-mode: both;
}
/* A named card has only an old or only a new picture; its group must
not also morph. */
::view-transition-group(*) {
animation-duration: var(--pm-duration);
}
}
@keyframes pm-out-left { to { transform: translateX(-18%); opacity: 0; } }
@keyframes pm-out-right { to { transform: translateX(18%); opacity: 0; } }
@keyframes pm-out-up { to { transform: translateY(-10%); opacity: 0; } }
@keyframes pm-out-down { to { transform: translateY(10%); opacity: 0; } }
@keyframes pm-in-right { from { transform: translateX(18%); opacity: 0; } }
@keyframes pm-in-left { from { transform: translateX(-18%); opacity: 0; } }
@keyframes pm-in-below { from { transform: translateY(10%); opacity: 0; } }
@keyframes pm-in-above { from { transform: translateY(-10%); opacity: 0; } }
/* The cards, one step apart: out first, in after the page has begun. */
@media (prefers-reduced-motion: no-preference) {
:root[data-motion="forward"]::view-transition-old(*) { animation-name: pm-out-left; }
:root[data-motion="forward"]::view-transition-new(*) { animation-name: pm-in-right; }
:root[data-motion="back"]::view-transition-old(*) { animation-name: pm-out-right; }
:root[data-motion="back"]::view-transition-new(*) { animation-name: pm-in-left; }
:root[data-motion="down"]::view-transition-old(*) { animation-name: pm-out-up; }
:root[data-motion="down"]::view-transition-new(*) { animation-name: pm-in-below; }
:root[data-motion="up"]::view-transition-old(*) { animation-name: pm-out-down; }
:root[data-motion="up"]::view-transition-new(*) { animation-name: pm-in-above; }
::view-transition-old(pm-out-1) { animation-delay: calc(0 * var(--pm-step)); }
::view-transition-new(pm-in-1) { animation-delay: calc(90ms + 0 * var(--pm-step)); }
::view-transition-old(pm-out-2) { animation-delay: calc(1 * var(--pm-step)); }
::view-transition-new(pm-in-2) { animation-delay: calc(90ms + 1 * var(--pm-step)); }
::view-transition-old(pm-out-3) { animation-delay: calc(2 * var(--pm-step)); }
::view-transition-new(pm-in-3) { animation-delay: calc(90ms + 2 * var(--pm-step)); }
::view-transition-old(pm-out-4) { animation-delay: calc(3 * var(--pm-step)); }
::view-transition-new(pm-in-4) { animation-delay: calc(90ms + 3 * var(--pm-step)); }
::view-transition-old(pm-out-5) { animation-delay: calc(4 * var(--pm-step)); }
::view-transition-new(pm-in-5) { animation-delay: calc(90ms + 4 * var(--pm-step)); }
::view-transition-old(pm-out-6) { animation-delay: calc(5 * var(--pm-step)); }
::view-transition-new(pm-in-6) { animation-delay: calc(90ms + 5 * var(--pm-step)); }
::view-transition-old(pm-out-7) { animation-delay: calc(6 * var(--pm-step)); }
::view-transition-new(pm-in-7) { animation-delay: calc(90ms + 6 * var(--pm-step)); }
::view-transition-old(pm-out-8) { animation-delay: calc(7 * var(--pm-step)); }
::view-transition-new(pm-in-8) { animation-delay: calc(90ms + 7 * var(--pm-step)); }
}