Copy round two approved: the gate opens
Six strings approved with no edits — the new headline and lede, the tear strip instruction, the AI disclosure in its new position, and placeholders for both fields so a ruled underline reads as somewhere to type. No placeholders remain, so the app now boots on the canonical host for the first time. That refusal was the whole point of the mechanism: the design and the engine were built, reviewed and deployed around copy that did not exist yet, and production stayed shut until it did. Links live beside the copy rather than inside it — a phrase and a URL, spliced in after escaping, so the copy table never becomes a place where markup is allowed. "projects we're working on" is three consecutive words pointing at three different projects, which is Paul's own joke and only works because they are adjacent. approved.json now carries all 44 strings. It is the durable record the drift guard reads, and it is deliberately not result.json: the review server deletes that when a round opens, which silently disarmed the guard at exactly the moment copy was most likely to move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
16 files changed,
+722
−346
.impeccable/hook.cache.json+1 −1copy-review/approved.json+258 −0copy-review/result.json+1 −1copy-review/server.log+5 −0copy-review/strings.json+28 −300internal/movement/copy.go+17 −9internal/movement/handlers.go+1 −1internal/movement/links.go+81 −0internal/movement/pages.go+18 −9internal/movement/static/bill.css+63 −0internal/movement/static/theme.css+8 −0internal/movement/templates/index.html+65 −5internal/movement/templates/stance.html+7 −2internal/movementtest/copy_test.go+63 −5internal/movementtest/joke_test.go+44 −13internal/movementtest/links_test.go+62 −0
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json| index 9dfc53f..1be0531 100644 |
| --- a/.impeccable/hook.cache.json |
| +++ b/.impeccable/hook.cache.json |
| @@ -1 +1 @@ |
| -{"version":1,"sessions":{"91e646a9-0b65-4ca7-83c7-59b544490463":{"updatedAt":1788371701138,"files":{"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/static/theme.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/static/bill.css":{"editCount":1,"findings":["design-system-font-size:163:1.25rem","design-system-font-size:224:1.6rem","design-system-font-size:283:1.75rem","design-system-font-size:450:3rem","design-system-font-size:486:1.5rem","design-system-font-size:522:2rem","design-system-font-size:530:0.9375rem","design-system-font-size:547:0.9375rem","design-system-font-size:552:0.75rem"],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/layout.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/index.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/stance.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/sent.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/confirm.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/signin.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/admin.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}} |
| \ No newline at end of file |
| +{"version":1,"sessions":{"91e646a9-0b65-4ca7-83c7-59b544490463":{"updatedAt":1788377190003,"files":{"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/static/theme.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/static/bill.css":{"editCount":1,"findings":["design-system-font-size:163:1.25rem","design-system-font-size:224:1.6rem","design-system-font-size:283:1.75rem","design-system-font-size:450:3rem","design-system-font-size:486:1.5rem","design-system-font-size:522:2rem","design-system-font-size:530:0.9375rem","design-system-font-size:547:0.9375rem","design-system-font-size:552:0.75rem"],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/layout.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/index.html":{"editCount":5,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/stance.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/sent.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/confirm.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/signin.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/admin.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}} |
| \ No newline at end of file |
diff --git a/copy-review/approved.json b/copy-review/approved.json| new file mode 100644 |
| index 0000000..7848ed4 |
| --- /dev/null |
| +++ b/copy-review/approved.json |
| @@ -0,0 +1,258 @@ |
| +{ |
| + "action": "approve", |
| + "guidance": null, |
| + "strings": [ |
| + { |
| + "id": "admin.col.background", |
| + "text": "Background", |
| + "original": "Background", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.col.email", |
| + "text": "Email", |
| + "original": "Email", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.col.stance", |
| + "text": "Stance", |
| + "original": "Stance", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.col.when", |
| + "text": "Confirmed", |
| + "original": "Confirmed", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.confirmed", |
| + "text": "confirmed", |
| + "original": "confirmed", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.empty", |
| + "text": "Nobody has confirmed yet.", |
| + "original": "Nobody has confirmed yet.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.export", |
| + "text": "Download CSV", |
| + "original": "Download CSV", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.pending", |
| + "text": "waiting to confirm", |
| + "original": "waiting to confirm", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.signout", |
| + "text": "Sign out", |
| + "original": "Sign out", |
| + "edited": false |
| + }, |
| + { |
| + "id": "admin.title", |
| + "text": "Signups", |
| + "original": "Signups", |
| + "edited": false |
| + }, |
| + { |
| + "id": "confirm.button", |
| + "text": "Confirm my email address", |
| + "original": "Confirm my email address", |
| + "edited": false |
| + }, |
| + { |
| + "id": "confirm.dead", |
| + "text": "That link has expired or has already been used. Signing up again will send you a fresh one.", |
| + "original": "That link has expired or has already been used. Signing up again will send you a fresh one.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "confirm.done", |
| + "text": "You're on the list.", |
| + "original": "You're on the list.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "confirm.title", |
| + "text": "One more click", |
| + "original": "One more click", |
| + "edited": false |
| + }, |
| + { |
| + "id": "form.background", |
| + "text": "Why are you interested, and how could you help?", |
| + "original": "Why are you interested, and how could you help?", |
| + "edited": false |
| + }, |
| + { |
| + "id": "form.background.placeholder", |
| + "text": "A few sentences is plenty." |
| + }, |
| + { |
| + "id": "form.bademail", |
| + "text": "That doesn't look like an email address.", |
| + "original": "That doesn't look like an email address.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "form.email", |
| + "text": "Your email address", |
| + "original": "Your email address", |
| + "edited": false |
| + }, |
| + { |
| + "id": "form.email.placeholder", |
| + "text": "you@example.com" |
| + }, |
| + { |
| + "id": "form.honeypot", |
| + "text": "Leave this field empty", |
| + "original": "Leave this field empty", |
| + "edited": false |
| + }, |
| + { |
| + "id": "form.nojs", |
| + "text": "JS required to help prevent bots.", |
| + "original": "Signing up needs JavaScript. Before the form will send, your browser does a small piece of arithmetic that costs a bot far more than it costs you — it is how we keep this open without a CAPTCHA company watching who visits. With JavaScript off there is no way through, and we would rather tell you that now than show you a form that quietly does nothing.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "form.submit", |
| + "text": "Sign up", |
| + "original": "Sign up", |
| + "edited": false |
| + }, |
| + { |
| + "id": "form.working", |
| + "text": "Working…", |
| + "original": "Working…", |
| + "edited": false |
| + }, |
| + { |
| + "id": "index.headline", |
| + "text": "Take your software home with CARLOS" |
| + }, |
| + { |
| + "id": "index.lede", |
| + "text": "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." |
| + }, |
| + { |
| + "id": "index.link", |
| + "text": "Read what CARLOS actually is", |
| + "original": "Read what CARLOS actually is", |
| + "edited": false |
| + }, |
| + { |
| + "id": "mail.confirm.body", |
| + "text": "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.", |
| + "original": "Someone — we hope you — 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.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "mail.confirm.subject", |
| + "text": "Confirm your email address", |
| + "original": "Confirm your email address", |
| + "edited": false |
| + }, |
| + { |
| + "id": "sent.body", |
| + "text": "If that address still needs confirming, there's a link on its way to it. Nobody joins this list without clicking one — including you.", |
| + "original": "If that address still needs confirming, there's a link on its way to it. Nobody joins this list without clicking one — including you.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "sent.title", |
| + "text": "Check your inbox", |
| + "original": "Check your inbox", |
| + "edited": false |
| + }, |
| + { |
| + "id": "signin.sent", |
| + "text": "If that address can sign in, there's a link on its way to it.", |
| + "original": "If that address can sign in, there's a link on its way to it.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "signin.submit", |
| + "text": "Send me a link", |
| + "original": "Send me a link", |
| + "edited": false |
| + }, |
| + { |
| + "id": "signin.title", |
| + "text": "Sign in", |
| + "original": "Sign in", |
| + "edited": false |
| + }, |
| + { |
| + "id": "slug.curious", |
| + "text": "uneasy", |
| + "original": "uneasy", |
| + "edited": false |
| + }, |
| + { |
| + "id": "slug.enthusiast", |
| + "text": "all-in", |
| + "original": "all-in", |
| + "edited": false |
| + }, |
| + { |
| + "id": "slug.never", |
| + "text": "no-ai", |
| + "original": "no-ai", |
| + "edited": false |
| + }, |
| + { |
| + "id": "stance.curious.label", |
| + "text": "Interested, but uneasy.", |
| + "original": "Interested, and uneasy about it.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "stance.curious.message", |
| + "text": "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!", |
| + "original": "So are we. AI means something different in almost every sentence it appears in, and most arguments about it are two people describing different things. Here 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. Here is what we also think is true: plenty of people are using it badly and should be told so, by name. Both at once. That's the whole position, and it's why we'd like to hear from you.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "stance.enthusiast.disclosure", |
| + "text": "The above text was entirely AI-generated." |
| + }, |
| + { |
| + "id": "stance.enthusiast.label", |
| + "text": "All in. Fully-pilled.", |
| + "original": "All in. The upside is obvious.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "stance.enthusiast.message", |
| + "text": "This was entirely AI-generated. Human editor leaving as-is:\n\nThen 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.", |
| + "original": "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.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "stance.never.label", |
| + "text": "No AI. Not for me.", |
| + "original": "No AI. Not for me.", |
| + "edited": false |
| + }, |
| + { |
| + "id": "stance.never.message", |
| + "text": "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.", |
| + "original": "Then we're probably closer than the labels suggest. Most of what people object to about AI, we object to as well: the scraping, the extraction, the confidence without accountability, the money sloshing through all of it. 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.", |
| + "edited": true |
| + }, |
| + { |
| + "id": "tear.instruction", |
| + "text": "Take the one that's yours." |
| + } |
| + ] |
| +} |
| \ No newline at end of file |
diff --git a/copy-review/result.json b/copy-review/result.json| index d7f5313..544bd2e 100644 |
| --- a/copy-review/result.json |
| +++ b/copy-review/result.json |
| @@ -1 +1 @@ |
| -{"action":"approve","guidance":null,"strings":[{"id":"index.headline","text":"AI is what you make with it","original":"We built it with AI. That's the part worth arguing about.","edited":true},{"id":"index.lede","text":"CARLOS is app infrastructure built from an ideal of building light weight, easily-distributable apps: one small binary, private databases, and low resource usage. This was using AI to unlock software dreamlines: ambitious technical constraints without profit as a primary motive. This is turning AI to building open systems that would otherwise take years of research and experimentation to build. What do you think?","original":"CARLOS is app infrastructure: one small binary, a private database for every person using it, and apps that fall asleep when nobody needs them. It exists because we wanted software shaped the way we always wished it were — no venture capital, no growth curve, nothing to monetise. We used AI to build it, deliberately and in the open. Before anything else, we'd like to know where you stand.","edited":true},{"id":"index.link","text":"Read what CARLOS actually is","original":"Read what CARLOS actually is","edited":false},{"id":"stance.never.label","text":"No AI. Not for me.","original":"No AI. Not for me.","edited":false},{"id":"stance.curious.label","text":"Interested, but uneasy.","original":"Interested, and uneasy about it.","edited":true},{"id":"stance.enthusiast.label","text":"All in. Fully-pilled.","original":"All in. The upside is obvious.","edited":true},{"id":"stance.never.message","text":"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.","original":"Then we're probably closer than the labels suggest. Most of what people object to about AI, we object to as well: the scraping, the extraction, the confidence without accountability, the money sloshing through all of it. 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.","edited":true},{"id":"stance.curious.message","text":"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!","original":"So are we. AI means something different in almost every sentence it appears in, and most arguments about it are two people describing different things. Here 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. Here is what we also think is true: plenty of people are using it badly and should be told so, by name. Both at once. That's the whole position, and it's why we'd like to hear from you.","edited":true},{"id":"stance.enthusiast.message","text":"This was entirely AI-generated. Human editor leaving as-is:\n\nThen 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.","original":"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.","edited":true},{"id":"slug.never","text":"no-ai","original":"no-ai","edited":false},{"id":"slug.curious","text":"uneasy","original":"uneasy","edited":false},{"id":"slug.enthusiast","text":"all-in","original":"all-in","edited":false},{"id":"form.email","text":"Your email address","original":"Your email address","edited":false},{"id":"form.background","text":"Why are you interested, and how could you help?","original":"Why are you interested, and how could you help?","edited":false},{"id":"form.submit","text":"Sign up","original":"Sign up","edited":false},{"id":"form.working","text":"Working…","original":"Working…","edited":false},{"id":"form.nojs","text":"JS required to help prevent bots.","original":"Signing up needs JavaScript. Before the form will send, your browser does a small piece of arithmetic that costs a bot far more than it costs you — it is how we keep this open without a CAPTCHA company watching who visits. With JavaScript off there is no way through, and we would rather tell you that now than show you a form that quietly does nothing.","edited":true},{"id":"form.bademail","text":"That doesn't look like an email address.","original":"That doesn't look like an email address.","edited":false},{"id":"form.honeypot","text":"Leave this field empty","original":"Leave this field empty","edited":false},{"id":"sent.title","text":"Check your inbox","original":"Check your inbox","edited":false},{"id":"sent.body","text":"If that address still needs confirming, there's a link on its way to it. Nobody joins this list without clicking one — including you.","original":"If that address still needs confirming, there's a link on its way to it. Nobody joins this list without clicking one — including you.","edited":false},{"id":"confirm.title","text":"One more click","original":"One more click","edited":false},{"id":"confirm.button","text":"Confirm my email address","original":"Confirm my email address","edited":false},{"id":"confirm.done","text":"You're on the list.","original":"You're on the list.","edited":false},{"id":"confirm.dead","text":"That link has expired or has already been used. Signing up again will send you a fresh one.","original":"That link has expired or has already been used. Signing up again will send you a fresh one.","edited":false},{"id":"mail.confirm.subject","text":"Confirm your email address","original":"Confirm your email address","edited":false},{"id":"mail.confirm.body","text":"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.","original":"Someone — we hope you — 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.","edited":true},{"id":"signin.title","text":"Sign in","original":"Sign in","edited":false},{"id":"signin.submit","text":"Send me a link","original":"Send me a link","edited":false},{"id":"signin.sent","text":"If that address can sign in, there's a link on its way to it.","original":"If that address can sign in, there's a link on its way to it.","edited":false},{"id":"admin.title","text":"Signups","original":"Signups","edited":false},{"id":"admin.confirmed","text":"confirmed","original":"confirmed","edited":false},{"id":"admin.pending","text":"waiting to confirm","original":"waiting to confirm","edited":false},{"id":"admin.export","text":"Download CSV","original":"Download CSV","edited":false},{"id":"admin.empty","text":"Nobody has confirmed yet.","original":"Nobody has confirmed yet.","edited":false},{"id":"admin.signout","text":"Sign out","original":"Sign out","edited":false},{"id":"admin.col.email","text":"Email","original":"Email","edited":false},{"id":"admin.col.stance","text":"Stance","original":"Stance","edited":false},{"id":"admin.col.when","text":"Confirmed","original":"Confirmed","edited":false},{"id":"admin.col.background","text":"Background","original":"Background","edited":false}]} |
| \ No newline at end of file |
| +{"action":"approve","guidance":null,"strings":[{"id":"index.headline","text":"Take your software home with CARLOS","original":"Take your software home with CARLOS","edited":false},{"id":"index.lede","text":"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.","original":"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.","edited":false},{"id":"tear.instruction","text":"Take the one that's yours.","original":"Take the one that's yours.","edited":false},{"id":"stance.enthusiast.disclosure","text":"The above text was entirely AI-generated.","original":"The above text was entirely AI-generated.","edited":false},{"id":"form.email.placeholder","text":"you@example.com","original":"you@example.com","edited":false},{"id":"form.background.placeholder","text":"A few sentences is plenty.","original":"A few sentences is plenty.","edited":false}]} |
| \ No newline at end of file |
diff --git a/copy-review/server.log b/copy-review/server.log| new file mode 100644 |
| index 0000000..fdb48f0 |
| --- /dev/null |
| +++ b/copy-review/server.log |
| @@ -0,0 +1,5 @@ |
| +REVIEW_URL: http://docs-t1.tail5fd3ba.ts.net:42745 |
| +REVIEW_URL_FALLBACK: http://100.119.180.57:42745 |
| +Copy review running |
| +Result written to copy-review/result.json |
| +Exiting: review submitted |
diff --git a/copy-review/strings.json b/copy-review/strings.json| index 366dcc8..13ec314 100644 |
| --- a/copy-review/strings.json |
| +++ b/copy-review/strings.json |
| @@ -3,320 +3,48 @@ |
| "id": "index.headline", |
| "section": "Landing page", |
| "label": "Headline", |
| - "text": "We built it with AI. That's the part worth arguing about.", |
| - "context": "The first thing anyone sees. It has to do two jobs at once, because you decided the page is about both CARLOS and the ethics of building with AI, with the tension between them as the message rather than a problem to resolve. It also has to earn the three-way question that follows.", |
| - "notes": "Your original draft was \"CARLOS is an app infrastructure designed to simplify how we build connected software\" — I moved that description into the paragraph below, because a headline that only describes the infrastructure gives someone no reason to answer a question about AI ethics." |
| + "text": "Take your software home with CARLOS", |
| + "context": "The first thing anyone sees, set in condensed caps at roughly 170px on a desktop. It replaces \"AI is what you make with it\", which you approved earlier and then asked to try this instead. It runs to three lines at that scale rather than two, which fills the sheet more like a poster.", |
| + "notes": "One thing to weigh: this frames CARLOS as ownership and sovereignty, which matches carlosframework.com better than the old line did — but it no longer sets up the AI question the three tabs ask. The lede still mentions AI, so the jump is bridged, just less directly. It also shares a verb with the strip instruction below it (\"Take the one that's yours\")." |
| }, |
| { |
| "id": "index.lede", |
| "section": "Landing page", |
| "label": "Opening paragraph", |
| - "text": "CARLOS is app infrastructure: one small binary, a private database for every person using it, and apps that fall asleep when nobody needs them. It exists because we wanted software shaped the way we always wished it were — no venture capital, no growth curve, nothing to monetise. We used AI to build it, deliberately and in the open. Before anything else, we'd like to know where you stand.", |
| - "context": "Sits under the headline and has to explain what CARLOS is to someone who has never heard of it, then hand over to the three choices. The description of the infrastructure needs to match carlosframework.com, because the link below goes there and that site leads with cost, security, privacy and sovereignty — a visitor who follows it should not meet a different pitch.", |
| - "notes": "Roughly four sentences. The last line is the hinge into the three options." |
| + "text": "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.", |
| + "context": "Sits under the headline, set across two hard ruled columns. It has to explain what CARLOS is to someone who has never heard of it and then hand over to the three positions. This is your text, dictated, replacing the shorter version you approved earlier.", |
| + "notes": "Third revision, applied as dictated. The doubled word from the earlier drafts is gone — that sentence was rewritten. \"That's the catch\" is now the pivot into the three positions below." |
| }, |
| { |
| - "id": "index.link", |
| + "id": "tear.instruction", |
| "section": "Landing page", |
| - "label": "Link out to carlosframework.com", |
| - "text": "Read what CARLOS actually is", |
| - "context": "A link to carlosframework.com for anyone who wants the technical story before answering a question about themselves. It should feel like an invitation to check us out rather than a marketing call to action.", |
| - "notes": "Link text only." |
| + "label": "The line above the tear-off strip", |
| + "text": "Take the one that's yours.", |
| + "context": "Sits at the foot of the bill, on the rule directly above the three tabs. It is the only instruction on the landing page, because there is no button — taking a tab IS how someone signs up. It has to make three positions read as something you act on rather than three headings you scroll past.", |
| + "notes": "Short. It shares a rule with the link out to carlosframework.com, so anything long will crowd it." |
| }, |
| { |
| - "id": "stance.never.label", |
| - "section": "The three choices", |
| - "label": "First choice — the label people click", |
| - "text": "No AI. Not for me.", |
| - "context": "The first of three positions on the landing page. Someone picks one and is taken to a page written specifically to that position. This one is for people who have decided they will not use AI at all. People do own this position proudly, so it can be stated flatly.", |
| - "notes": "Your draft: \"Never AI - I will never use AI\". Kept the meaning, shortened it." |
| + "id": "stance.enthusiast.disclosure", |
| + "section": "The \"All in\" page", |
| + "label": "The AI disclosure, under the reply", |
| + "text": "The above text was entirely AI-generated.", |
| + "context": "Sits below the reply on the all-in page only, separated by a rule and set smaller than the text it describes. This is your joke: the people who said they are all in on AI are the ones handed the model's output verbatim, and this is the line that says so. It used to sit above the text; you moved it below on 2026-09-03.", |
| + "notes": "Your wording, drafted from what you asked for. Confirming it here rather than assuming, since it now reads as a statement of fact on a public page." |
| }, |
| { |
| - "id": "stance.curious.label", |
| - "section": "The three choices", |
| - "label": "Second choice — the label people click", |
| - "text": "Interested, and uneasy about it.", |
| - "context": "The middle option, and the group this whole page exists to find. It has to be recognisable enough that the right person reads it and thinks that's me, without being so flattering that everyone picks it.", |
| - "notes": "Your draft: \"AI curious, ethically skeptical\"." |
| - }, |
| - { |
| - "id": "stance.enthusiast.label", |
| - "section": "The three choices", |
| - "label": "Third choice — the label people click", |
| - "text": "All in. The upside is obvious.", |
| - "context": "The third position, for people enthusiastic about AI who find the ethical debate overblown or somebody else's problem. This is the one I changed most, and deliberately.", |
| - "notes": "Your draft was \"Love AI, never thought about its ethical issues\", which describes that group accurately but asks them to volunteer that they have not thought about something. Nobody clicks that — they would pick the middle option instead and pollute the one signal this page exists to isolate. This version is a position someone would state proudly, so the people you want to identify actually identify themselves. The diagnosis stays in your reading of the bucket, not on the button." |
| - }, |
| - { |
| - "id": "stance.never.message", |
| - "section": "Page for \"No AI\"", |
| - "label": "Response to someone who will not use AI", |
| - "text": "Then we're probably closer than the labels suggest. Most of what people object to about AI, we object to as well: the scraping, the extraction, the confidence without accountability, the money sloshing through all of it. 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.", |
| - "context": "Shown to someone who has just said they will never use AI, immediately above the signup form. Your brief said we're probably more aligned than not, and that we chose to use AI to build systems in the shape we'd always wished, without the constraints of venture capital or the need to monetise. It should not try to talk them round.", |
| - "notes": "They can still sign up — the form is directly beneath this." |
| - }, |
| - { |
| - "id": "stance.curious.message", |
| - "section": "Page for \"Interested, and uneasy\"", |
| - "label": "Response to the group you are looking for", |
| - "text": "So are we. AI means something different in almost every sentence it appears in, and most arguments about it are two people describing different things. Here 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. Here is what we also think is true: plenty of people are using it badly and should be told so, by name. Both at once. That's the whole position, and it's why we'd like to hear from you.", |
| - "context": "Shown to the middle group — the people this page is built to find. Your brief: we want to encourage discussion and hold bad actors to account, but recognise that AI means different things to different people, and that in software it is particularly placed to unlock new ways of doing things.", |
| - "notes": "This is the one that has to land. Everything else on the page is arranged around finding the person who reads this and agrees." |
| - }, |
| - { |
| - "id": "stance.enthusiast.message", |
| - "section": "Page for \"All in\"", |
| - "label": "Response to the enthusiastic group", |
| - "text": "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.", |
| - "context": "Shown to someone who has just said they are all in on AI. Your brief was blunt: probably not our people, because a community of conscientious people is the point, and acting without considering consequences or purely on profit leads to toxicity, individualism and a lack of humanity. This says the unflattering thing while leaving the door open, since all three groups can sign up.", |
| - "notes": "The hardest one to get right — it has to be honest without being sneering, or it just reads as smug." |
| - }, |
| - { |
| - "id": "slug.never", |
| - "section": "Web addresses", |
| - "label": "URL for the \"No AI\" page", |
| - "text": "no-ai", |
| - "context": "This becomes part of the web address someone lands on and can share — movement.bdj.oncarlos.com/no-ai. It is visible in the browser bar and in anything anyone pastes into a chat.", |
| - "notes": "Lowercase, hyphens, no spaces. Currently the internal name 'never' is used as a placeholder." |
| - }, |
| - { |
| - "id": "slug.curious", |
| - "section": "Web addresses", |
| - "label": "URL for the \"Interested, and uneasy\" page", |
| - "text": "uneasy", |
| - "context": "The web address for the middle group's page — movement.bdj.oncarlos.com/uneasy. This is the one most likely to get shared deliberately, so it is worth it reading well on its own.", |
| - "notes": "Lowercase, hyphens, no spaces." |
| - }, |
| - { |
| - "id": "slug.enthusiast", |
| - "section": "Web addresses", |
| - "label": "URL for the \"All in\" page", |
| - "text": "all-in", |
| - "context": "The web address for the enthusiastic group's page — movement.bdj.oncarlos.com/all-in.", |
| - "notes": "Lowercase, hyphens, no spaces." |
| - }, |
| - { |
| - "id": "form.email", |
| - "section": "Signup form", |
| - "label": "Email field label", |
| - "text": "Your email address", |
| - "context": "Labels the email box on all three signup pages.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "form.background", |
| + "id": "form.email.placeholder", |
| "section": "Signup form", |
| - "label": "Free-text field label", |
| - "text": "Why are you interested, and how could you help?", |
| - "context": "Labels the big text box under the email field. This is the field that does the real work — you read what people write here to decide who they are. It should invite a few sentences rather than a word.", |
| - "notes": "Capped at 2000 characters." |
| + "label": "Placeholder in the email field", |
| + "text": "you@example.com", |
| + "context": "Grey text inside the email box before anyone types. The fields are ruled underlines rather than boxes, so without this they read as rules on a page rather than as somewhere to type — which is why you asked for it.", |
| + "notes": "Placeholder text disappears on typing, so it can never carry information somebody needs to keep. The label above the field still says what it is." |
| }, |
| { |
| - "id": "form.submit", |
| + "id": "form.background.placeholder", |
| "section": "Signup form", |
| - "label": "Submit button", |
| - "text": "Sign up", |
| - "context": "The button that sends the form. It starts out disabled and switches on once the page's JavaScript has loaded.", |
| - "notes": "Short." |
| - }, |
| - { |
| - "id": "form.working", |
| - "section": "Signup form", |
| - "label": "Button label while the browser is busy", |
| - "text": "Working…", |
| - "context": "Replaces the button label for a second or two after someone presses it. The browser is doing a small piece of deliberate arithmetic in the background — the thing that makes bulk signups expensive for bots — and without a label change the button looks broken on a slow phone.", |
| - "notes": "Very short. Could be \"One moment…\" or anything that signals the wait is expected." |
| - }, |
| - { |
| - "id": "form.nojs", |
| - "section": "Signup form", |
| - "label": "Message when JavaScript is switched off", |
| - "text": "Signing up needs JavaScript. Before the form will send, your browser does a small piece of arithmetic that costs a bot far more than it costs you — it is how we keep this open without a CAPTCHA company watching who visits. With JavaScript off there is no way through, and we would rather tell you that now than show you a form that quietly does nothing.", |
| - "context": "Shown only to someone browsing with JavaScript disabled, in place of a working form. They genuinely cannot sign up — you chose that trade deliberately over a weaker fallback — so this has to explain the situation honestly rather than apologise vaguely.", |
| - "notes": "This is the string I would most like you to check. It is the only thing a locked-out visitor ever hears from us." |
| - }, |
| - { |
| - "id": "form.bademail", |
| - "section": "Signup form", |
| - "label": "Error when the address is malformed", |
| - "text": "That doesn't look like an email address.", |
| - "context": "Shown above the form when someone submits something that is not a valid address. It is the only visible error on the whole signup path — every other refusal renders the ordinary confirmation page, because saying more would let a stranger work out who is already on the list.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "form.honeypot", |
| - "section": "Signup form", |
| - "label": "Label on the hidden anti-bot field", |
| - "text": "Leave this field empty", |
| - "context": "A field that is invisible on screen but exists in the page to catch automated signups. Nobody sees it, but a screen reader may read the label aloud, so it needs to tell that person to skip it rather than confuse them.", |
| - "notes": "Only ever heard, never seen." |
| - }, |
| - { |
| - "id": "sent.title", |
| - "section": "After signing up", |
| - "label": "Confirmation page heading", |
| - "text": "Check your inbox", |
| - "context": "Shown after any submission. It is deliberately the same page whether the address is new, already waiting, or already on the list — otherwise anyone could type an address and learn from the response whether that person had signed up.", |
| - "notes": "Read the note on the paragraph below before changing this — the wording has to stay true in all three cases." |
| - }, |
| - { |
| - "id": "sent.body", |
| - "section": "After signing up", |
| - "label": "Confirmation page text", |
| - "text": "If that address still needs confirming, there's a link on its way to it. Nobody joins this list without clicking one — including you.", |
| - "context": "The paragraph under the heading above, and the trickiest string on the page. It is shown to three different people: someone who just signed up and will get an email, someone who signed up before and is getting another, and someone already confirmed who gets nothing at all.", |
| - "notes": "It has to be true for all three, which is why it says \"if that address still needs confirming\" rather than \"we've sent you an email\". Saying the second would be a lie to the third person, and telling them apart is exactly what we refuse to do." |
| - }, |
| - { |
| - "id": "confirm.title", |
| - "section": "Confirming from the email", |
| - "label": "Heading on the confirmation page", |
| - "text": "One more click", |
| - "context": "Shown when someone follows the link in their email. There is a button underneath, and pressing it is what puts them on the list. The extra click exists because corporate mail scanners open every link in an arriving message, and without it those scanners would confirm people who never clicked anything.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "confirm.button", |
| - "section": "Confirming from the email", |
| - "label": "The confirm button", |
| - "text": "Confirm my email address", |
| - "context": "The button that finishes the signup. It is the last action anyone takes.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "confirm.done", |
| - "section": "Confirming from the email", |
| - "label": "Success message", |
| - "text": "You're on the list.", |
| - "context": "Shown once someone has confirmed. Given what we promise, it should not oversell what happens next — occasional updates about CARLOS is the whole offer.", |
| - "notes": "You may want a second sentence here setting expectations. I kept it to one on the grounds that anything more starts sounding like a welcome pack for a thing that does not exist yet." |
| - }, |
| - { |
| - "id": "confirm.dead", |
| - "section": "Confirming from the email", |
| - "label": "Expired or already-used link", |
| - "text": "That link has expired or has already been used. Signing up again will send you a fresh one.", |
| - "context": "Shown for a link that is too old, already clicked, or simply wrong. All three cases get this one message, because distinguishing them would leak whether an address is on the list.", |
| - "notes": "Links last 24 hours." |
| - }, |
| - { |
| - "id": "mail.confirm.subject", |
| - "section": "Confirmation email", |
| - "label": "Subject line", |
| - "text": "Confirm your email address", |
| - "context": "The subject of the only email we send during signup. It arrives from a CARLOS address the recipient has probably never seen, so it should be plain and obviously legitimate rather than clever.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "mail.confirm.body", |
| - "section": "Confirmation email", |
| - "label": "Email body", |
| - "text": "Someone — we hope you — 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.", |
| - "context": "The body of the confirmation email. The link is added on the line after this text automatically. It has to work for someone who did sign up and for someone whose address was typed in by a stranger, and tell the second person that doing nothing is the right move.", |
| - "notes": "Plain text only — no formatting, no HTML. The link appears on its own line beneath." |
| - }, |
| - { |
| - "id": "signin.title", |
| - "section": "Admin sign-in", |
| - "label": "Sign-in page heading", |
| - "text": "Sign in", |
| - "context": "The page where you sign in to read the list. Only you see this.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "signin.submit", |
| - "section": "Admin sign-in", |
| - "label": "Sign-in button", |
| - "text": "Send me a link", |
| - "context": "There is no password — you type your address and get a link by email.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "signin.sent", |
| - "section": "Admin sign-in", |
| - "label": "Message after requesting a link", |
| - "text": "If that address can sign in, there's a link on its way to it.", |
| - "context": "Shown after the sign-in form is submitted, whether or not the address is allowed in. Saying \"that address cannot sign in\" would let anyone work out who the admins are, so both cases get this.", |
| - "notes": "Same shape as the public confirmation page, for the same reason." |
| - }, |
| - { |
| - "id": "admin.title", |
| - "section": "Admin", |
| - "label": "Admin page heading", |
| - "text": "Signups", |
| - "context": "The heading on the page listing everyone who has confirmed. Only you see it.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.confirmed", |
| - "section": "Admin", |
| - "label": "Label on the confirmed count", |
| - "text": "confirmed", |
| - "context": "Follows a number at the top of the admin page — \"12 confirmed\".", |
| - "notes": "Lowercase, sits after a number." |
| - }, |
| - { |
| - "id": "admin.pending", |
| - "section": "Admin", |
| - "label": "Label on the pending count", |
| - "text": "waiting to confirm", |
| - "context": "Follows a number — \"3 waiting to confirm\". Pending signups are counted but never listed, because an address nobody has proven owns, sitting next to a stated position on AI, is the pair we take most care with.", |
| - "notes": "Lowercase, sits after a number." |
| - }, |
| - { |
| - "id": "admin.export", |
| - "section": "Admin", |
| - "label": "Export link", |
| - "text": "Download CSV", |
| - "context": "Downloads the confirmed list as a spreadsheet file. Only confirmed rows are ever included.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.empty", |
| - "section": "Admin", |
| - "label": "Empty state", |
| - "text": "Nobody has confirmed yet.", |
| - "context": "Shown in place of the table before anyone has been through the whole signup and confirmation flow.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.signout", |
| - "section": "Admin", |
| - "label": "Sign-out button", |
| - "text": "Sign out", |
| - "context": "Ends your admin session.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.col.email", |
| - "section": "Admin", |
| - "label": "Table column — email", |
| - "text": "Email", |
| - "context": "Column heading in the list of confirmed signups.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.col.stance", |
| - "section": "Admin", |
| - "label": "Table column — stance", |
| - "text": "Stance", |
| - "context": "Column heading showing which of the three positions each person picked.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.col.when", |
| - "section": "Admin", |
| - "label": "Table column — date", |
| - "text": "Confirmed", |
| - "context": "Column heading for the date someone confirmed.", |
| - "notes": "" |
| - }, |
| - { |
| - "id": "admin.col.background", |
| - "section": "Admin", |
| - "label": "Table column — what they wrote", |
| - "text": "Background", |
| - "context": "Column heading for what each person wrote about why they are interested and how they could help.", |
| - "notes": "" |
| + "label": "Placeholder in the free-text field", |
| + "text": "A few sentences is plenty.", |
| + "context": "Grey text inside the large box under \"Why are you interested, and how could you help?\". This is the field that does the real work — you read what people write here to decide who they are — so the placeholder's job is to set the expected length without making it feel like an exam.", |
| + "notes": "Same rule: it vanishes on typing, so it cannot carry anything essential." |
| } |
| -] |
| +] |
| \ No newline at end of file |
diff --git a/internal/movement/copy.go b/internal/movement/copy.go| index 3445966..937e04b 100644 |
| --- a/internal/movement/copy.go |
| +++ b/internal/movement/copy.go |
| @@ -27,25 +27,33 @@ type entry struct { |
| var table = map[string]entry{ |
| // The landing page and the three choices. |
| // |
| - // stance.enthusiast.message opens with an editorial note, on purpose. |
| - // It is the joke: the people who said they are all in on AI are the ones |
| - // handed the model's output with a human editor's shrug attached, rather |
| - // than something written for them. It is copy, not a stray annotation — |
| - // Paul confirmed that on 2026-09-02 after it was queried. |
| - "index.headline": {"AI is what you make with it", false}, |
| - "index.lede": {"CARLOS is app infrastructure built from an ideal of building light weight, easily-distributable apps: one small binary, private databases, and low resource usage. This was using AI to unlock software dreamlines: ambitious technical constraints without profit as a primary motive. This is turning AI to building open systems that would otherwise take years of research and experimentation to build. What do you think?", false}, |
| + // The all-in reply carries a disclosure, on purpose. It is the joke: the |
| + // people who said they are all in on AI are the ones handed the model's |
| + // output verbatim. Paul confirmed that on 2026-09-02 after it was |
| + // queried, and on 2026-09-03 moved it below the text it describes — |
| + // which is why it is now stance.enthusiast.disclosure rather than a |
| + // prefix inside the message. |
| + "index.headline": {"Take your software home with CARLOS", false}, |
| + "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}, |
| "index.link": {"Read what CARLOS actually is", false}, |
| "stance.never.label": {"No AI. Not for me.", false}, |
| "stance.curious.label": {"Interested, but uneasy.", false}, |
| "stance.enthusiast.label": {"All in. Fully-pilled.", false}, |
| "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}, |
| "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}, |
| - "stance.enthusiast.message": {"This was entirely AI-generated. Human editor leaving as-is:\n\nThen 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}, |
| + "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}, |
| + |
| + // Awaiting review: the disclosure's new position and wording, and the |
| + // field placeholders. Marked as placeholders, so the canonical host |
| + // refuses to serve them. |
| + "stance.enthusiast.disclosure": {"The above text was entirely AI-generated.", false}, |
| + "form.email.placeholder": {"you@example.com", false}, |
| + "form.background.placeholder": {"A few sentences is plenty.", false}, |
| // The tear-off strip. Not yet reviewed: the design needed words the copy |
| // round never saw, so they ship as placeholders and the canonical host |
| // refuses to serve them. |
| - "tear.instruction": {"PLACEHOLDER take the one that is yours", true}, |
| + "tear.instruction": {"Take the one that's yours.", false}, |
| // The form. |
| "form.email": {"Your email address", false}, |
diff --git a/internal/movement/handlers.go b/internal/movement/handlers.go| index 44e7500..3d5b2fd 100644 |
| --- a/internal/movement/handlers.go |
| +++ b/internal/movement/handlers.go |
| @@ -43,7 +43,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| }) |
| } |
| render(w, "index", map[string]any{ |
| - "Lede": Paragraphs("index.lede"), |
| + "Lede": Linked("index.lede"), |
| "Choices": choices, |
| }) |
| } |
diff --git a/internal/movement/links.go b/internal/movement/links.go| new file mode 100644 |
| index 0000000..2a280a0 |
| --- /dev/null |
| +++ b/internal/movement/links.go |
| @@ -0,0 +1,81 @@ |
| +package movement |
| + |
| +import ( |
| + "html/template" |
| + "strings" |
| +) |
| + |
| +// Links inside copy, without putting markup inside copy. |
| +// |
| +// The alternative was to store the lede with anchors in it and render it |
| +// through template.HTML, which turns the copy table into a place where |
| +// escaping is off and one careless edit is an injection. Instead the copy |
| +// stays plain text — reviewable as words, diffable as words — and the links |
| +// are structured data beside it: a phrase, and where it points. |
| +// |
| +// The phrase has to appear in the copy verbatim. When copy is rewritten and a |
| +// phrase stops matching, the link silently stops appearing, so a test asserts |
| +// every phrase still resolves rather than trusting that nobody reworded |
| +// around it. |
| + |
| +type copyLink struct { |
| + Phrase string |
| + URL string |
| +} |
| + |
| +var links = map[string][]copyLink{ |
| + "index.lede": { |
| + {"philosophy", "https://11factor.org"}, |
| + {"software ideals", "https://carlosframework.com/"}, |
| + |
| + // Three consecutive words, three destinations. Paul's call, and it |
| + // only works because the words are adjacent: "projects we’re working |
| + // on" reads as one phrase and turns out to be three doors. The space |
| + // between them is not underlined, so they stay visibly separate |
| + // links rather than one long one. |
| + {"projects", "https://elevenmessenger.com/"}, |
| + {"we’re", "https://rastrillo.org/"}, |
| + {"working on", "https://carloku.com/"}, |
| + }, |
| +} |
| + |
| +// Linked renders one copy string as HTML: escaped first, then anchors spliced |
| +// in for each phrase. Escaping happens before any markup is added, so nothing |
| +// in the copy itself can become markup. |
| +func Linked(id string) []template.HTML { |
| + ls := links[id] |
| + |
| + out := make([]template.HTML, 0, 4) |
| + for _, para := range Paragraphs(id) { |
| + safe := escape(para) |
| + for _, l := range ls { |
| + needle := escape(l.Phrase) |
| + if !strings.Contains(safe, needle) { |
| + continue |
| + } |
| + anchor := `<a class="lede-link" href="` + escape(l.URL) + |
| + `" target="_blank" rel="noopener noreferrer">` + needle + `</a>` |
| + // Once only: a phrase that recurs should not become a row of |
| + // identical links down the paragraph. |
| + safe = strings.Replace(safe, needle, anchor, 1) |
| + } |
| + out = append(out, template.HTML(safe)) |
| + } |
| + return out |
| +} |
| + |
| +// LinkPhrases lists the phrases a copy id expects to find, so a test can |
| +// check they are all still present after a rewrite. |
| +func LinkPhrases(id string) []string { |
| + var out []string |
| + for _, l := range links[id] { |
| + out = append(out, l.Phrase) |
| + } |
| + return out |
| +} |
| + |
| +func escape(s string) string { |
| + var b strings.Builder |
| + template.HTMLEscape(&b, []byte(s)) |
| + return b.String() |
| +} |
diff --git a/internal/movement/pages.go b/internal/movement/pages.go| index 7ccf34c..d3c18c4 100644 |
| --- a/internal/movement/pages.go |
| +++ b/internal/movement/pages.go |
| @@ -16,10 +16,14 @@ type stanceData struct { |
| // Message is the response to this position, split into paragraphs. Copy |
| // is written the way a person writes it, with the breaks meant; rendered |
| // as one string they collapse into a wall. |
| - Message []string |
| - Challenge Challenge |
| - Honeypot string |
| - Error string |
| + Message []string |
| + // Disclosure is set only where a stance's reply carries one. Today that |
| + // is the all-in page, whose reply is the model's output handed over |
| + // verbatim — the note says so, under the text it describes. |
| + Disclosure string |
| + Challenge Challenge |
| + Honeypot string |
| + Error string |
| } |
| // stancePage renders one of the three positions and the form beneath it. |
| @@ -35,12 +39,17 @@ func (a *app) stancePage(stance Stance) http.HandlerFunc { |
| } |
| func (a *app) renderStance(w http.ResponseWriter, stance Stance, errMsg string) { |
| + disclosure := "" |
| + if stance == StanceEnthusiast { |
| + disclosure = T("stance.enthusiast.disclosure") |
| + } |
| render(w, "stance", stanceData{ |
| - Stance: string(stance), |
| - Message: Paragraphs("stance." + string(stance) + ".message"), |
| - Challenge: NewChallenge(a.challengeKey, time.Now(), DefaultDifficulty), |
| - Honeypot: honeypotField, |
| - Error: errMsg, |
| + Stance: string(stance), |
| + Message: Paragraphs("stance." + string(stance) + ".message"), |
| + Disclosure: disclosure, |
| + Challenge: NewChallenge(a.challengeKey, time.Now(), DefaultDifficulty), |
| + Honeypot: honeypotField, |
| + Error: errMsg, |
| }) |
| } |
diff --git a/internal/movement/static/bill.css b/internal/movement/static/bill.css| index 179a88c..ac372e2 100644 |
| --- a/internal/movement/static/bill.css |
| +++ b/internal/movement/static/bill.css |
| @@ -563,3 +563,66 @@ body { |
| color: var(--bill-dim-bone); |
| max-width: 28rem; |
| } |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The pills |
| + Drawn on the stock in one stroke weight. Solid, not multiplied: a |
| + marker does not overprint with what was already printed under it. |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.pill { |
| + display: block; |
| + width: clamp(3.5rem, 6vw, 5rem); |
| + height: auto; |
| + margin-bottom: 0.55rem; |
| + overflow: visible; |
| + transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1); |
| +} |
| + |
| +/* The tab lifts; the pill rolls very slightly with it, the way a drawn |
| + thing does when the paper it is on moves. */ |
| +.tab:hover .pill, |
| +.tab:focus-visible .pill { |
| + transform: rotate(-3deg) translateY(-2px); |
| +} |
| + |
| +@media (prefers-reduced-motion: reduce) { |
| + .pill, |
| + .tab:hover .pill, |
| + .tab:focus-visible .pill { |
| + transition: none; |
| + transform: none; |
| + } |
| +} |
| + |
| +@media (max-width: 46rem) { |
| + .pill { |
| + width: 3.25rem; |
| + margin-bottom: 0.35rem; |
| + } |
| +} |
| + |
| +/* The all-in reply's disclosure. Set apart from the text it describes by a |
| + rule, and quieter than it: it is a note about the copy, not more copy. */ |
| +.sheet__disclosure { |
| + margin-top: 1.6rem; |
| + padding-top: 0.85rem; |
| + border-top: 2px solid var(--rst-line); |
| + font-size: var(--t-xs); |
| + font-weight: 700; |
| + letter-spacing: 0.04em; |
| + color: var(--bill-dim-bone); |
| +} |
| + |
| +/* Links inside the argument. They sit in the body's own colour with the |
| + second ink under them, so they read as marked-up text rather than as |
| + buttons dropped into a paragraph. */ |
| +.lede-link { |
| + color: inherit; |
| + text-decoration: none; |
| + box-shadow: inset 0 -0.34em 0 rgba(20, 160, 200, 0.45); |
| + transition: box-shadow 160ms ease-out; |
| +} |
| +.lede-link:hover { |
| + box-shadow: inset 0 -0.9em 0 rgba(20, 160, 200, 0.5); |
| +} |
diff --git a/internal/movement/static/theme.css b/internal/movement/static/theme.css| index a17b690..1402170 100644 |
| --- a/internal/movement/static/theme.css |
| +++ b/internal/movement/static/theme.css |
| @@ -50,6 +50,14 @@ |
| --bill-ink: #17140f; |
| --bill-blue: #1b2a8f; |
| --bill-cyan: #14a0c8; /* the second ink */ |
| + |
| + /* The sharpie. A third pen rather than a third press ink: a marker drawn |
| + ON the stock does not overprint with what was printed under it, so the |
| + pills render solid while everything else multiplies. */ |
| + --pill-blue: #1f3fd0; |
| + --pill-blue-line: #12246f; |
| + --pill-red: #cf2233; |
| + --pill-red-line: #7d1119; |
| --bill-over: #2f6b46; /* what cyan makes of the yellow stock */ |
| --bill-dim: #6b5f14; /* the ink thinned, for secondary text ON yellow */ |
| --bill-dim-bone: #6a6455; |
diff --git a/internal/movement/templates/index.html b/internal/movement/templates/index.html| index 5a9f9eb..b371a26 100644 |
| --- a/internal/movement/templates/index.html |
| +++ b/internal/movement/templates/index.html |
| @@ -1,5 +1,62 @@ |
| {{define "surface"}}bill-page{{end}} |
| {{define "title"}}{{say "index.headline"}}{{end}} |
| + |
| +{{/* ── The pills ────────────────────────────────────────────────────────── |
| + Drawn, not typeset: authored SVG in one stroke weight, wobbled so they |
| + read as sharpie on stock rather than as a vector shape. Each path |
| + overshoots its corner slightly, which is what a marker does when the |
| + hand does not stop exactly on the join. |
| + |
| + They are solid where everything else in the system multiplies, and that |
| + is the point of the distinction: a marker drawn ON paper does not |
| + overprint with the ink already printed under it. It is a third pen, not |
| + a third press ink. |
| + |
| + "Fully-pilled" was already the red-pill reference; the pills only make |
| + the copy literal. The middle option is a pill snapped in half rather |
| + than a purple one, because purple says "a blend of both positions" and |
| + a broken pill says "swallowed neither, and took it apart to look |
| + inside" — which is the group this page exists to find. */}} |
| + |
| +{{define "pill-blue"}} |
| +<svg class="pill" viewBox="0 0 140 64" aria-hidden="true" focusable="false"> |
| + <path d="M37 8 C20 8.5 8.5 19 8 31.5 C7.6 44.5 20 55.5 36.5 56 L103 56.4 |
| + C120 56 132 45 131.5 31.6 C131 19 119.5 8.2 103.5 8 Z" |
| + fill="var(--pill-blue)" stroke="var(--pill-blue-line)" stroke-width="5" |
| + stroke-linejoin="round"/> |
| + <path d="M70.5 11 C69.4 24 70.8 40 69.8 53.5" stroke="var(--pill-blue-line)" |
| + stroke-width="4.5" stroke-linecap="round" fill="none"/> |
| +</svg> |
| +{{end}} |
| + |
| +{{define "pill-broken"}} |
| +<svg class="pill" viewBox="0 0 140 64" aria-hidden="true" focusable="false"> |
| + <g transform="rotate(-5 62 32)"> |
| + <path d="M37 8 C20 8.5 8.5 19 8 31.5 C7.6 44.5 20 55.5 36.5 56 L64 56.3 |
| + L57 45 L66 34 L56.5 22 L63.5 8.2 Z" |
| + fill="var(--pill-blue)" stroke="var(--pill-blue-line)" stroke-width="5" |
| + stroke-linejoin="round"/> |
| + </g> |
| + <g transform="rotate(6 96 32)"> |
| + <path d="M79 8.2 L86 22 L76.5 34 L85.5 45 L78.5 56.3 L103 56.4 |
| + C120 56 132 45 131.5 31.6 C131 19 119.5 8.2 103.5 8 Z" |
| + fill="var(--pill-red)" stroke="var(--pill-red-line)" stroke-width="5" |
| + stroke-linejoin="round"/> |
| + </g> |
| +</svg> |
| +{{end}} |
| + |
| +{{define "pill-red"}} |
| +<svg class="pill" viewBox="0 0 140 64" aria-hidden="true" focusable="false"> |
| + <path d="M37 8 C20 8.5 8.5 19 8 31.5 C7.6 44.5 20 55.5 36.5 56 L103 56.4 |
| + C120 56 132 45 131.5 31.6 C131 19 119.5 8.2 103.5 8 Z" |
| + fill="var(--pill-red)" stroke="var(--pill-red-line)" stroke-width="5" |
| + stroke-linejoin="round"/> |
| + <path d="M70.5 11 C69.4 24 70.8 40 69.8 53.5" stroke="var(--pill-red-line)" |
| + stroke-width="4.5" stroke-linecap="round" fill="none"/> |
| +</svg> |
| +{{end}} |
| + |
| {{define "content"}} |
| <div class="bill"> |
| @@ -23,21 +80,24 @@ |
| address. |
| Each tab carries the address it leads to and nothing else, because |
| - that is what a tear-off tab is FOR: the thing you walk away holding. A |
| - separate "take" label said the same thing twice. Deliberately not |
| - numbered — numbering three positions implies a rank, and these three |
| - are equal by design. */}} |
| + that is what a tear-off tab is FOR: the thing you walk away holding. |
| + Deliberately not numbered — numbering three positions implies a rank, |
| + and these three are equal by design. */}} |
| <section class="tear"> |
| <div class="tear__head"> |
| <p class="tear__instruction">{{say "tear.instruction"}}</p> |
| <p class="bill__slug"> |
| - <a class="bill__out" href="https://carlosframework.com">{{say "index.link"}}</a> |
| + <a class="bill__out" href="https://carlosframework.com" |
| + target="_blank" rel="noopener noreferrer">{{say "index.link"}}</a> |
| </p> |
| </div> |
| <div class="tear__perf"></div> |
| <div class="tear__tabs"> |
| {{range .Choices}} |
| <a class="tab" href="/{{.Slug}}"> |
| + {{if eq .Slug "no-ai"}}{{template "pill-blue"}} |
| + {{else if eq .Slug "all-in"}}{{template "pill-red"}} |
| + {{else}}{{template "pill-broken"}}{{end}} |
| <span class="tab__word">{{.Label}}</span> |
| <span class="tab__addr">movement.bdj.oncarlos.com/{{.Slug}}</span> |
| </a> |
diff --git a/internal/movement/templates/stance.html b/internal/movement/templates/stance.html| index 84e46ec..7f4ecce 100644 |
| --- a/internal/movement/templates/stance.html |
| +++ b/internal/movement/templates/stance.html |
| @@ -14,6 +14,9 @@ |
| <div class="sheet__reply"> |
| {{range .Message}}<p>{{.}}</p>{{end}} |
| + {{/* Only the all-in reply carries it, and it sits under the text it |
| + describes rather than in front of it. */}} |
| + {{if .Disclosure}}<p class="sheet__disclosure">{{.Disclosure}}</p>{{end}} |
| </div> |
| <form class="slip" method="post" action="/signup" |
| @@ -46,12 +49,14 @@ |
| <label class="field" for="email"> |
| <span>{{say "form.email"}}</span> |
| <input type="email" id="email" name="email" required |
| - autocomplete="email" data-pow-email> |
| + autocomplete="email" data-pow-email |
| + placeholder="{{say "form.email.placeholder"}}"> |
| </label> |
| <label class="field" for="background"> |
| <span>{{say "form.background"}}</span> |
| - <textarea id="background" name="background" rows="5" maxlength="2000"></textarea> |
| + <textarea id="background" name="background" rows="5" maxlength="2000" |
| + placeholder="{{say "form.background.placeholder"}}"></textarea> |
| </label> |
| {{/* Disabled until the module loads and enables it. The other way round |
diff --git a/internal/movementtest/copy_test.go b/internal/movementtest/copy_test.go| index 1b6f5ab..e7d2e99 100644 |
| --- a/internal/movementtest/copy_test.go |
| +++ b/internal/movementtest/copy_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "encoding/json" |
| "os" |
| "path/filepath" |
| + "strings" |
| "testing" |
| movement "amadan.net/carlos/movement/internal/movement" |
| @@ -18,7 +19,12 @@ import ( |
| // nothing would notice. copy-review/result.json is the record of what was |
| // approved, so it is the thing to compare against. |
| -const reviewResult = "../../copy-review/result.json" |
| +// The durable record of what has been approved. NOT result.json: the review |
| +// server deletes that when a new round starts, so a test pointed at it loses |
| +// its guard the moment somebody opens another round — which is exactly when |
| +// copy is most likely to move. approved.json is written from a finished |
| +// round and outlives the next one. |
| +const reviewResult = "../../copy-review/approved.json" |
| type reviewedString struct { |
| ID string `json:"id"` |
| @@ -46,13 +52,38 @@ var notInTheTable = map[string]string{ |
| "slug.enthusiast": "routing, not display copy", |
| } |
| -// One submitted string opens "This was entirely AI-generated. Human editor |
| +// One submitted string opened "This was entirely AI-generated. Human editor |
| // leaving as-is:" and it was queried, because it reads like a note to whoever |
| // is applying the copy. It is not. Paul confirmed on 2026-09-02 that it is |
| // the joke: the people who said they are all in on AI get the model's output |
| -// with a human editor's shrug attached, rather than something written for |
| -// them. It ships, so nothing here strips anything — every string is compared |
| -// exactly as submitted. |
| +// verbatim. |
| +// |
| +// On 2026-09-03 he moved it: the disclosure now sits AFTER the text it |
| +// describes, reworded, as its own string awaiting its own review. So the |
| +// message shipped is the submitted text minus that prefix, which is the one |
| +// place this file allows a difference — and it names it rather than |
| +// tolerating a mismatch quietly. |
| + |
| +// changedAfterReview records strings the user directed a change to after |
| +// approving them. Each needs a date and what they asked for, so that a |
| +// difference from the review record is always a decision somebody made and |
| +// never drift nobody noticed. |
| +var changedAfterReview = map[string]string{ |
| + "stance.enthusiast.message": "2026-09-03: Paul moved the AI disclosure " + |
| + "below the text it describes, so the prefix left this string and " + |
| + "became stance.enthusiast.disclosure, which awaits its own review.", |
| +} |
| + |
| +// pendingAgain is computed from the copy table itself rather than listed by |
| +// hand: anything the app still marks as a placeholder is mid-round, whatever |
| +// it was approved as previously. |
| +var pendingAgain = func() map[string]bool { |
| + m := map[string]bool{} |
| + for _, id := range movement.Placeholders() { |
| + m[id] = true |
| + } |
| + return m |
| +}() |
| func TestEveryStringMatchesTheApprovedReview(t *testing.T) { |
| raw, err := os.ReadFile(filepath.FromSlash(reviewResult)) |
| @@ -74,6 +105,33 @@ func TestEveryStringMatchesTheApprovedReview(t *testing.T) { |
| continue |
| } |
| + // A string that has been re-marked as a placeholder is awaiting a new |
| + // round, so the old approved text is no longer what it should equal. |
| + // Production still refuses to serve it, which is the real guard. |
| + if pendingAgain[s.ID] { |
| + t.Logf("%s: re-opened for review, not compared", s.ID) |
| + continue |
| + } |
| + |
| + if why, changed := changedAfterReview[s.ID]; changed { |
| + // Still checked, but against what the change left behind: the |
| + // submitted text with the moved prefix removed. |
| + const prefix = "This was entirely AI-generated. Human editor leaving as-is:" |
| + rest, found := strings.CutPrefix(s.Text, prefix) |
| + if !found { |
| + t.Errorf("%s is recorded as changed after review (%s) but no "+ |
| + "longer matches the shape that change expected", s.ID, why) |
| + continue |
| + } |
| + if got := movement.T(s.ID); got != strings.TrimLeft(rest, "\n ") { |
| + t.Errorf("%s differs from the approved text by more than the "+ |
| + "change on record (%s).\n approved: %q\n shipped : %q", |
| + s.ID, why, s.Text, got) |
| + } |
| + checked++ |
| + continue |
| + } |
| + |
| want := s.Text |
| got := movement.T(s.ID) |
| if got != want { |
diff --git a/internal/movementtest/joke_test.go b/internal/movementtest/joke_test.go| index b9c91f0..0d310c9 100644 |
| --- a/internal/movementtest/joke_test.go |
| +++ b/internal/movementtest/joke_test.go |
| @@ -1,19 +1,22 @@ |
| package movementtest |
| import ( |
| + "html/template" |
| "net/http" |
| "strings" |
| "testing" |
| + |
| + movement "amadan.net/carlos/movement/internal/movement" |
| ) |
| -// The all-in page hands its reader the model's output with a human editor's |
| -// shrug attached, and that shrug is the joke — so it has to be visible, on |
| -// that page, and nowhere else. Rendered as one blob it reads as a stray note |
| -// somebody forgot to delete; on the wrong page it reads as an apology. |
| -func TestTheEditorialJokeRendersOnTheAllInPageOnly(t *testing.T) { |
| +// The all-in page hands its reader the model's output and then says so. The |
| +// disclosure is the joke, so it has to be visible, on that page, and nowhere |
| +// else — and since 2026-09-03 it sits AFTER the text it describes, because a |
| +// note about copy that arrives before the copy is just a preamble. |
| +func TestTheDisclosureFollowsTheAllInReplyAndAppearsNowhereElse(t *testing.T) { |
| h := newApp(t) |
| - const line = "This was entirely AI-generated. Human editor leaving as-is:" |
| + line := movement.T("stance.enthusiast.disclosure") |
| rec := get(t, h, "/all-in") |
| if rec.Code != http.StatusOK { |
| @@ -21,18 +24,46 @@ func TestTheEditorialJokeRendersOnTheAllInPageOnly(t *testing.T) { |
| } |
| body := rec.Body.String() |
| - if !strings.Contains(body, line) { |
| - t.Error("the editorial line is missing from the all-in page") |
| + at := strings.Index(body, line) |
| + if at < 0 { |
| + t.Fatal("the disclosure is missing from the all-in page") |
| } |
| - // Its own paragraph: the blank line in the copy is what separates the |
| - // aside from the message it is commenting on. |
| - if !strings.Contains(body, "<p>"+line+"</p>") { |
| - t.Error("the editorial line should stand as its own paragraph, not run into the message") |
| + |
| + // After the reply, not before it: the last paragraph of the message has |
| + // to appear earlier in the document than the note about it. |
| + // |
| + // Compared escaped, because that is what reaches the page — the copy has |
| + // apostrophes and html/template renders them as ', so a raw |
| + // comparison fails on correct output. |
| + msg := movement.Paragraphs("stance.enthusiast.message") |
| + var esc strings.Builder |
| + template.HTMLEscape(&esc, []byte(msg[len(msg)-1])) |
| + |
| + if end := strings.Index(body, esc.String()); end < 0 { |
| + t.Fatal("the all-in reply is missing its last paragraph") |
| + } else if end > at { |
| + t.Error("the disclosure appears before the text it describes") |
| } |
| for _, other := range []string{"/no-ai", "/uneasy"} { |
| if strings.Contains(get(t, h, other).Body.String(), line) { |
| - t.Errorf("%s carries the all-in page's aside", other) |
| + t.Errorf("%s carries the all-in page's disclosure", other) |
| + } |
| + } |
| +} |
| + |
| +// Both fields say what goes in them. A ruled underline with nothing in it |
| +// reads as a rule, not as somewhere to type. |
| +func TestFieldsCarryPlaceholders(t *testing.T) { |
| + h := newApp(t) |
| + body := get(t, h, "/uneasy").Body.String() |
| + |
| + for _, id := range []string{"form.email.placeholder", "form.background.placeholder"} { |
| + if !strings.Contains(body, movement.T(id)) { |
| + t.Errorf("%s never reaches the form", id) |
| } |
| } |
| + if strings.Count(body, "placeholder=") < 2 { |
| + t.Error("both the address and the background field need a placeholder") |
| + } |
| } |
diff --git a/internal/movementtest/links_test.go b/internal/movementtest/links_test.go| new file mode 100644 |
| index 0000000..a843be9 |
| --- /dev/null |
| +++ b/internal/movementtest/links_test.go |
| @@ -0,0 +1,62 @@ |
| +package movementtest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + movement "amadan.net/carlos/movement/internal/movement" |
| +) |
| + |
| +// Links live beside the copy rather than inside it, matched by phrase. That |
| +// keeps markup out of the copy table, and it means a rewrite can silently |
| +// orphan a link by rewording around its phrase. Nothing would look broken — |
| +// the paragraph would just quietly stop linking — so it is checked here. |
| +func TestEveryLinkPhraseStillAppearsInItsCopy(t *testing.T) { |
| + for _, id := range []string{"index.lede"} { |
| + body := strings.Join(movement.Paragraphs(id), " ") |
| + for _, phrase := range movement.LinkPhrases(id) { |
| + if !strings.Contains(body, phrase) { |
| + t.Errorf("%s links the phrase %q, which the copy no longer "+ |
| + "contains — the link has been orphaned by a rewrite", id, phrase) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestLedeLinksRenderAndOpenInANewTab(t *testing.T) { |
| + h := newApp(t) |
| + body := get(t, h, "/").Body.String() |
| + |
| + for _, want := range []string{ |
| + `href="https://11factor.org"`, |
| + `href="https://carlosframework.com/"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the lede is missing %s", want) |
| + } |
| + } |
| + // Every outbound link leaves in its own tab, and carries the rel that |
| + // stops the new page reaching back into this one. |
| + if strings.Count(body, `rel="noopener noreferrer"`) < 3 { |
| + t.Error("an outbound link is missing target/rel") |
| + } |
| +} |
| + |
| +// The copy is escaped before any anchor is spliced in, so nothing written in |
| +// the copy table can become markup. |
| +func TestLedeCopyCannotBecomeMarkup(t *testing.T) { |
| + h := newApp(t) |
| + rec := get(t, h, "/") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /: %d", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + |
| + // The apostrophes in the copy must arrive escaped, which is the visible |
| + // proof that escaping ran before the links went in. |
| + if strings.Contains(body, "We’ve built") && !strings.Contains(body, "'") && |
| + !strings.Contains(body, "’") { |
| + t.Error("copy reached the page without passing through escaping") |
| + } |
| +} |