/** * The selling points, and which four of them a visitor is shown. * * This is the only part of the landing page that varies. The headline, the * lede, the screenshot and its caption, the prices and the closing line are * written once and stay put — they are the page's argument, and an argument has * to hold together to be worth reading. What rotates is the middle: which four * reasons, out of all the true ones, this visitor gets. * * That scope is deliberate and was arrived at the hard way. A page whose * headline and lede also rotated could no longer be written as one thing; every * line had to survive being read beside every other, so each of them ended up * saying everything and selling nothing. Selling points do not have that * problem. They are a list by nature, four out of nine reads exactly like four * out of nine, and which four is a real question nobody can answer from taste. * * Each point is its own parameter. There is deliberately nothing here saying * what a point is *like* — no tone, no category, no coordinates. Any such * scheme would be one person's theory of what matters, fixed in advance, and * the model could then only ever discover what that theory already allowed. * Whether two points do the same job is a question for the data, and * `server/src/voice/` answers it: the covariance over which points were shown * together, against how the people who saw them went on to behave, is what * relates them. * * Two rules for editing this file: * * 1. An id is a permanent name for one point. Rewriting the text under an * existing id silently merges two different claims into one parameter and * poisons the history behind it. Changed wording means a new id; the old id * stops being drawn and ages out of the window on its own. A faithful * translation is the same claim in another language and keeps its id — but * changing what a point *claims* means a new id, in every language at once. * 2. Everything here has to be true of the product as shipped. The page is * eight lines from a demo anyone can open, and the whole argument is that * this thing does not lie to you about your data. * * The heading sells and the paragraph proves. That order matters: a heading * that only states a mechanism makes the reader do the work of caring, and a * paragraph that only enthuses gives them nothing to check. */ import type { Locale } from './locale.js'; export interface VoicePoint { /** Permanent. See rule 1 above. */ id: string; /** What the reader gets. Read on its own, it should be worth something. */ heading: Record; /** How it works, and the doubt the heading raises. */ text: Record; } /** How many of them one page shows. */ export const VOICE_PER_PAGE = 4; export const VOICE_POOL: VoicePoint[] = [ { id: 'p.totals', heading: { en: 'You never have to re-check a total', nl: 'Een totaal hoef je nooit na te rekenen', }, text: { en: 'Every total is worked out from the rows at the moment you look at it, not typed in once ' + 'and left behind. That is also why you can filter and sort on one — and why sorting by ' + 'the biggest order reaches every row the view matches, not just the two hundred your ' + 'browser has loaded.', nl: 'Elk totaal wordt uitgerekend uit de rijen op het moment dat je kijkt, niet één keer ' + 'ingetypt en achtergelaten. Daarom kun je er ook op filteren en sorteren — en daarom ' + 'bereikt sorteren op de grootste order elke rij die bij de weergave hoort, niet alleen ' + 'de tweehonderd die je browser geladen had.', }, }, { id: 'p.handover', heading: { en: 'Nobody walks off with the only set of keys', nl: 'Niemand loopt weg met de enige sleutelbos', }, text: { en: 'Invite the next person as an admin, send them the link however you already talk, and go. ' + 'Wellformed refuses to let the last admin leave until somebody else can get back in, so a ' + 'workspace is never left locked with the business inside it.', nl: 'Nodig de volgende persoon uit als beheerder, stuur de link via waar jullie toch al ' + 'praten, en ga. Wellformed weigert de laatste beheerder te laten vertrekken tot iemand ' + 'anders er weer in kan, dus een werkruimte blijft nooit op slot achter met de zaak erin.', }, }, { id: 'p.audit', heading: { en: 'You can always find out who changed it', nl: 'Je kunt altijd achterhalen wie het veranderd heeft', }, text: { en: 'Who, what, when, and the old value beside the new one. New rows, deletions, a column ' + 'added, somebody’s access changed — written down as it happens and impossible to take ' + 'back out afterwards. Undoing something is itself a change everyone can see.', nl: 'Wie, wat, wanneer, en de oude waarde naast de nieuwe. Nieuwe rijen, verwijderingen, een ' + 'kolom erbij, iemands toegang gewijzigd — vastgelegd terwijl het gebeurt en achteraf ' + 'onmogelijk weg te poetsen. Iets ongedaan maken is zelf een wijziging die iedereen kan ' + 'zien.', }, }, { id: 'p.export', heading: { en: 'You can walk away with everything, any day', nl: 'Je kunt elke dag met alles vertrekken', }, text: { en: 'A workspace is one SQLite file. Backing up is copying it, leaving is downloading it, and ' + 'Python, R, Excel and a dozen free viewers already read the format. There is no export to ' + 'request and no lock-in to argue about.', nl: 'Een werkruimte is één SQLite-bestand. Back-uppen is het kopiëren, vertrekken is het ' + 'downloaden, en Python, R, Excel en een dozijn gratis viewers lezen het formaat al. Er is ' + 'geen export om aan te vragen en geen lock-in om over te twisten.', }, }, { id: 'p.import', heading: { en: 'Start with the spreadsheet you already have', nl: 'Begin met de spreadsheet die je al hebt', }, text: { en: 'Drop in the .csv, .xlsx or .ods. It skips the title somebody typed above the table, the ' + 'blank rows under it, and the columns nobody ever filled in — then shows you which row it ' + 'took for the headers, so you can correct it when it guesses wrong. Nothing is written ' + 'until you agree.', nl: 'Zet de .csv, .xlsx of .ods erin. Hij slaat de titel over die iemand boven de tabel ' + 'typte, de lege rijen eronder, en de kolommen die nooit iemand invulde — en laat dan zien ' + 'welke rij hij voor de kopjes aanzag, zodat je het verbetert als hij mis gokt. Er wordt ' + 'niets geschreven tot jij akkoord gaat.', }, }, { id: 'p.links', heading: { en: 'Your data cannot come apart', nl: 'Je data kan niet uit elkaar raken', }, text: { en: 'An order names a customer instead of repeating it in a column that slowly stops ' + 'matching, so fixing the address once fixes every order. Delete a customer that orders ' + 'still point at and it refuses, and tells you which orders — rather than leaving you rows ' + 'pointing at nobody.', nl: 'Een order noemt een klant in plaats van hem te herhalen in een kolom die langzaam niet ' + 'meer klopt, dus het adres één keer verbeteren verbetert elke order. Verwijder een klant ' + 'waar orders nog naar wijzen en hij weigert, en vertelt je welke orders — in plaats van ' + 'rijen achter te laten die naar niemand wijzen.', }, }, { id: 'p.views', heading: { en: 'Everyone gets their own view of the same data', nl: 'Iedereen zijn eigen weergave van dezelfde data', }, text: { en: 'The filtered, sorted arrangement somebody needs every Monday is saved as a view over the ' + 'same rows, not as another tab that starts drifting the day it is copied. One set of ' + 'numbers, and as many windows onto it as there are people.', nl: 'De gefilterde, gesorteerde opstelling die iemand elke maandag nodig heeft wordt ' + 'opgeslagen als weergave over dezelfde rijen, niet als nóg een tabblad dat gaat afdrijven ' + 'op de dag dat het gekopieerd wordt. Eén stel cijfers, en zoveel vensters erop als er ' + 'mensen zijn.', }, }, { id: 'p.sql', heading: { en: 'You can check any number it shows you', nl: 'Elk getal dat hij laat zien kun je controleren', }, text: { en: 'Along the bottom of every screen is the exact statement the server ran to produce what ' + 'you are looking at. Most people will never read it. It is there because software asking ' + 'to hold your business should be able to show its working on the day somebody finally ' + 'asks.', nl: 'Onderaan elk scherm staat het exacte statement dat de server uitvoerde voor wat je nu ' + 'ziet. De meeste mensen zullen het nooit lezen. Het staat er omdat software die vraagt om ' + 'je zaak te mogen bewaren, moet kunnen voorrekenen wat ze doet op de dag dat iemand er ' + 'eindelijk om vraagt.', }, }, { id: 'p.perworkspace', heading: { en: 'Add everyone, pay once', nl: 'Voeg iedereen toe, betaal één keer', }, text: { en: 'The price is per workspace rather than per person, so the colleague who opens it twice a ' + 'year and the accountant checking one figure both get in. Nobody is kept out of the data ' + 'to keep the bill down — which is exactly how the private copy on somebody’s laptop gets ' + 'started.', nl: 'De prijs is per werkruimte in plaats van per persoon, dus de collega die hem twee keer ' + 'per jaar opent en de accountant die één cijfer controleert komen er allebei in. Niemand ' + 'wordt buiten de data gehouden om de rekening laag te houden — en precies zo begint de ' + 'privékopie op iemands laptop.', }, }, ]; const BY_ID = new Map(VOICE_POOL.map((point) => [point.id, point])); export function voicePoint(id: string): VoicePoint | undefined { return BY_ID.get(id); } /** What the server hands the page: which points to show, and what to call the visit. */ export interface VoiceAssignment { /** The impression id, quoted back at signup so the reward finds its way home. */ id: string; /** Point ids, in no particular order. */ items: string[]; } /** * Turn a list of ids into the four points to render. * * Tolerant on purpose. A browser holding yesterday's bundle can be handed an id * it has never heard of, and half a page of selling points is a far worse * outcome than one that quietly falls back. The fallback is deterministic * rather than random, so a stale client at least stays consistent with itself * between reloads. */ export function resolveVoice(items: string[]): VoicePoint[] { const chosen = items .map((id) => BY_ID.get(id)) .filter((point): point is VoicePoint => point !== undefined) .slice(0, VOICE_PER_PAGE); for (const fallback of VOICE_POOL) { if (chosen.length >= VOICE_PER_PAGE) break; if (!chosen.includes(fallback)) chosen.push(fallback); } return chosen; }