| 1 | package movement |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/url" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // This app is its copy. The words are the mechanism that sorts people into |
| 11 | // buckets, not decoration on top of one — so every one of them went through |
| 12 | // review with Paul before it reached this file, and what is here is his text, |
| 13 | // applied verbatim. |
| 14 | // |
| 15 | // Holding them in a table rather than in markup is what let the whole engine |
| 16 | // be built and canary-reviewed before the words existed. The placeholder |
| 17 | // machinery below stays for the same reason next time. |
| 18 | |
| 19 | type entry struct { |
| 20 | text string |
| 21 | // placeholder marks copy that has not been through review. Everything |
| 22 | // here has, so nothing sets it today — it exists for the next string |
| 23 | // somebody adds. |
| 24 | placeholder bool |
| 25 | } |
| 26 | |
| 27 | var table = map[string]entry{ |
| 28 | // The landing page and the three choices. |
| 29 | // |
| 30 | // The all-in reply carries a disclosure, on purpose. It is the joke: the |
| 31 | // people who said they are all in on AI are the ones handed the model's |
| 32 | // output verbatim. Paul confirmed that on 2026-09-02 after it was |
| 33 | // queried, and on 2026-09-03 moved it below the text it describes — |
| 34 | // which is why it is now stance.enthusiast.disclosure rather than a |
| 35 | // prefix inside the message. |
| 36 | "index.headline": {"Take your software home with CARLOS", false}, |
| 37 | "index.lede": {"CARLOS is an anti-corporatist movement combining philosophy, a set of software ideals, and a collection of tools and reference apps. The projects we’re working on are fast, modern, and secure. They’re self-hostable. Build and deployment is a breeze. Web and native apps can be built and run anywhere with high-availability, durability and resiliency, with massive performance and planet scale. It’s no compromise computing, except: we used AI. That’s the catch. We’re looking for folks to join our movement, but we want to start honestly with responsible disclosure.", false}, |
| 38 | "index.link": {"Read what CARLOS actually is", false}, |
| 39 | "stance.never.label": {"No AI. Not for me.", false}, |
| 40 | "stance.curious.label": {"Interested, but uneasy.", false}, |
| 41 | "stance.enthusiast.label": {"All in. Fully-pilled.", false}, |
| 42 | "stance.never.message": {"We're probably closer than the labels suggest. Most of what people object to about AI, we object to as well: the scraping without consent, the extractive behaviour, the confidence without accountability, the profit motive, the bait-and-switch, the weird people, and the overblown hype. We made a different call. We used it to build the software we always wanted and could never have justified building by hand: with no funder to answer to and nothing to monetise. You may still think that was the wrong call. We'd genuinely like to hear why.", false}, |
| 43 | "stance.curious.message": {"So are we. AI means something different in almost every sentence it appears in. Most arguments about it are not about the technology, but about shitty people doing shitty things.\n\nHere is what we think is true: it lets a small number of people build infrastructure that used to need a company, and that changes who gets to own their software.\n\nHere is what we also think is true: plenty of people are using it badly. There’s potential to outdo that by building better. The more skepticism, the better, but also ambition!", false}, |
| 44 | "stance.enthusiast.message": {"Then we'll be honest with you: you may not be our people. Not because enthusiasm is wrong — we're building with this stuff too — but because we think the consequences are the interesting part. A project that only ever asks whether something can be built, and never whether it should, ends up somewhere we don't want to live. If that reads as hand-wringing, this probably isn't your movement. If it reads as fair, and you'd argue the other side of it well, sign up anyway. We'd rather be disagreed with than agreed with by default.", false}, |
| 45 | |
| 46 | // Awaiting review: the disclosure's new position and wording, and the |
| 47 | // field placeholders. Marked as placeholders, so the canonical host |
| 48 | // refuses to serve them. |
| 49 | "stance.enthusiast.disclosure": {"The above text was entirely AI-generated.", false}, |
| 50 | "form.email.placeholder": {"you@example.com", false}, |
| 51 | "form.background.placeholder": {"A few sentences is plenty.", false}, |
| 52 | |
| 53 | // The tear-off strip. Not yet reviewed: the design needed words the copy |
| 54 | // round never saw, so they ship as placeholders and the canonical host |
| 55 | // refuses to serve them. |
| 56 | "tear.instruction": {"Take the one that's yours.", false}, |
| 57 | |
| 58 | // Awaiting review: the name field, added 2026-09-03. |
| 59 | "form.name": {"Your name", false}, |
| 60 | "form.name.placeholder.never": {"Ned Ludd", false}, |
| 61 | "form.name.placeholder.curious": {"John Cagey", false}, |
| 62 | "form.name.placeholder.enthusiast": {"Max Tokens", false}, |
| 63 | "form.badname": {"We need something to call you.", false}, |
| 64 | |
| 65 | // The form. |
| 66 | "form.email": {"Your email address", false}, |
| 67 | "form.background": {"Why are you interested, and how could you help?", false}, |
| 68 | "form.submit": {"Sign up", false}, |
| 69 | "form.working": {"Working…", false}, |
| 70 | "form.bademail": {"That doesn't look like an email address.", false}, |
| 71 | "form.honeypot": {"Leave this field empty", false}, |
| 72 | |
| 73 | // The one page every outcome renders. Its wording is load-bearing: it is |
| 74 | // shown to an unknown address, a pending one and a confirmed one alike, so |
| 75 | // it cannot say anything that would only be true of one. |
| 76 | "sent.title": {"Check your inbox", false}, |
| 77 | "sent.body": {"If that address still needs confirming, there's a link on its way to it. Nobody joins this list without clicking one — including you.", false}, |
| 78 | |
| 79 | // Confirmation. |
| 80 | "confirm.title": {"One more click", false}, |
| 81 | "confirm.button": {"Confirm my email address", false}, |
| 82 | "confirm.done": {"You're on the list.", false}, |
| 83 | "confirm.dead": {"That link has expired or has already been used. Signing up again will send you a fresh one.", false}, |
| 84 | |
| 85 | // Mail. |
| 86 | "mail.confirm.subject": {"Confirm your email address", false}, |
| 87 | "mail.confirm.body": {"This email address signed up to hear occasionally about CARLOS. Click the link below and you're on the list. Nothing happens until you do, and if it wasn't you, ignoring this is enough.", false}, |
| 88 | |
| 89 | // Admin. |
| 90 | "signin.title": {"Sign in", false}, |
| 91 | "signin.submit": {"Continue", false}, |
| 92 | "signin.sent": {"If that address can sign in, there's a link on its way to it.", false}, |
| 93 | "admin.title": {"Signups", false}, |
| 94 | "admin.confirmed": {"confirmed", false}, |
| 95 | "admin.pending": {"waiting to confirm", false}, |
| 96 | "admin.export": {"Download CSV", false}, |
| 97 | "admin.empty": {"Nobody has confirmed yet.", false}, |
| 98 | "admin.signout": {"Sign out", false}, |
| 99 | "admin.col.name": {"Name", false}, |
| 100 | "admin.col.email": {"Email", false}, |
| 101 | "admin.col.stance": {"Stance", false}, |
| 102 | "admin.col.when": {"Confirmed", false}, |
| 103 | "admin.col.background": {"Background", false}, |
| 104 | } |
| 105 | |
| 106 | // T resolves a copy id. |
| 107 | // |
| 108 | // It panics on an unknown id rather than rendering empty. A missing string |
| 109 | // should fail in a test, loudly, rather than ship as a blank space nobody |
| 110 | // notices until a visitor does. |
| 111 | func T(id string) string { |
| 112 | e, ok := table[id] |
| 113 | if !ok { |
| 114 | panic("movement: unknown copy id " + id) |
| 115 | } |
| 116 | return e.text |
| 117 | } |
| 118 | |
| 119 | // CopyIDs lists every id in the table, so a test can check that nothing in |
| 120 | // here shipped without going through review. |
| 121 | func CopyIDs() []string { |
| 122 | ids := make([]string, 0, len(table)) |
| 123 | for id := range table { |
| 124 | ids = append(ids, id) |
| 125 | } |
| 126 | sort.Strings(ids) |
| 127 | return ids |
| 128 | } |
| 129 | |
| 130 | // Paragraphs splits a string on blank lines so a template can render each as |
| 131 | // its own paragraph. Copy is written the way a person writes it, with the |
| 132 | // breaks meant; without this they collapse into one block of text. |
| 133 | func Paragraphs(id string) []string { |
| 134 | var out []string |
| 135 | for _, p := range strings.Split(T(id), "\n\n") { |
| 136 | if p = strings.TrimSpace(p); p != "" { |
| 137 | out = append(out, p) |
| 138 | } |
| 139 | } |
| 140 | return out |
| 141 | } |
| 142 | |
| 143 | // Placeholders lists the ids still awaiting copy review, sorted so the output |
| 144 | // is stable enough to diff between builds. |
| 145 | func Placeholders() []string { |
| 146 | ids := make([]string, 0, len(table)) |
| 147 | for id, e := range table { |
| 148 | if e.placeholder { |
| 149 | ids = append(ids, id) |
| 150 | } |
| 151 | } |
| 152 | sort.Strings(ids) |
| 153 | return ids |
| 154 | } |
| 155 | |
| 156 | // CheckPlaceholders refuses the canonical host while any copy is unreviewed, |
| 157 | // and allows everywhere else. |
| 158 | // |
| 159 | // The asymmetry is the point. The shipping cadence reviews a branch on a |
| 160 | // deployed canary, and a canary serves the same binary production would — so |
| 161 | // a build-time refusal would make an engine-only branch impossible to review, |
| 162 | // while no refusal at all would let unreviewed words reach readers. Checking |
| 163 | // the origin at boot is what separates the two. |
| 164 | func CheckPlaceholders(origin, canonicalHost string) error { |
| 165 | left := Placeholders() |
| 166 | if len(left) == 0 { |
| 167 | return nil |
| 168 | } |
| 169 | u, err := url.Parse(origin) |
| 170 | if err != nil { |
| 171 | return fmt.Errorf("movement: cannot parse origin %q", origin) |
| 172 | } |
| 173 | if u.Hostname() == canonicalHost { |
| 174 | return fmt.Errorf( |
| 175 | "movement: %d copy strings have not been through review; refusing to serve %s", |
| 176 | len(left), canonicalHost) |
| 177 | } |
| 178 | return nil |
| 179 | } |
| 180 | |