Paul's copy, applied verbatim, and the gate opens
Thirty-six reviewed strings replace the placeholders, byte for byte. A test compares the shipped table against copy-review/result.json so "applied verbatim" stays true after today rather than being a claim about one afternoon — a later session tidying a heading would otherwise be silently overwriting reviewed words. The three slugs land as routes: /no-ai, /uneasy, /all-in. The stored stance keys do not change, because a slug is copy and copy changes, while a value in the database has to outlive any wording or every row already written means something else afterwards. The JavaScript explanation is gone at Paul's instruction — proof of work is our implementation problem, not a visitor's. The consequence is recorded in both the spec and AGENTS.md rather than left as a doc that describes a rule the code no longer follows: a no-JS visitor now meets a disabled button and no reason, which is a dead end rather than a deception, and is why the button must never render enabled. Two bugs found by the tests rather than by a visitor: the slug routes were static so chi.URLParam returned nothing and every stance page 404'd, and the bad-address test compared raw text against HTML-escaped output. With no placeholders left the app boots on movement.bdj.oncarlos.com, which it refused to do this morning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 files changed,
+636
−106
AGENTS.md+9 −4copy-review/result.json+1 −0copy-review/strings.json+322 −0docs/superpowers/specs/2026-09-01-movement-design.md+14 −6internal/movement/app.go+5 −3internal/movement/copy.go+79 −66internal/movement/copy_test.go+1 −1internal/movement/handlers.go+17 −1internal/movement/models.go+26 −0internal/movement/pages.go+13 −11internal/movement/templates/index.html+11 −1internal/movement/templates/stance.html+9 −10internal/movementtest/copy_test.go+119 −0internal/movementtest/signup_test.go+10 −3
diff --git a/AGENTS.md b/AGENTS.md| index fe7711a..940ad55 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -190,10 +190,15 @@ copy off production. |
| - Run `rastrillo doctor --fix` after a framework upgrade; the scaffold's pin |
| test is the gate that catches a stale `static/tokens.css`. |
| - Signing up requires JavaScript (spec ruling 2). Render the submit button |
| - `disabled` with the explanation inside `<noscript>`, and let the module |
| - remove `disabled` once it loads. JavaScript cannot enable a control *inside* |
| - `<noscript>`, and this ordering fails safe: a module that throws leaves the |
| - honest disabled state rather than a live-looking form that does nothing. |
| + `disabled` and let the module remove `disabled` once it loads. JavaScript |
| + cannot enable a control *inside* `<noscript>`, and this ordering fails safe: |
| + a module that throws leaves the button disabled rather than live and inert. |
| +- **The page does not explain why it needs JavaScript.** Ruled by Paul, |
| + 2026-09-02, after copy review: proof of work is our implementation problem, |
| + not a visitor's, and a paragraph about bots and arithmetic is noise on a page |
| + asking someone a question about themselves. A no-JS visitor meets a disabled |
| + button and no explanation. That is a dead end, and an accepted one — but it |
| + is the reason the button must never render enabled. |
| - The proof-of-work solver runs in a Worker. On the main thread it freezes a |
| phone for seconds. |
diff --git a/copy-review/result.json b/copy-review/result.json| new file mode 100644 |
| index 0000000..d7f5313 |
| --- /dev/null |
| +++ b/copy-review/result.json |
| @@ -0,0 +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 |
diff --git a/copy-review/strings.json b/copy-review/strings.json| new file mode 100644 |
| index 0000000..366dcc8 |
| --- /dev/null |
| +++ b/copy-review/strings.json |
| @@ -0,0 +1,322 @@ |
| +[ |
| + { |
| + "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." |
| + }, |
| + { |
| + "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." |
| + }, |
| + { |
| + "id": "index.link", |
| + "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." |
| + }, |
| + { |
| + "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.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", |
| + "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." |
| + }, |
| + { |
| + "id": "form.submit", |
| + "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": "" |
| + } |
| +] |
diff --git a/docs/superpowers/specs/2026-09-01-movement-design.md b/docs/superpowers/specs/2026-09-01-movement-design.md| index e2ae7a9..f04bbbe 100644 |
| --- a/docs/superpowers/specs/2026-09-01-movement-design.md |
| +++ b/docs/superpowers/specs/2026-09-01-movement-design.md |
| @@ -265,12 +265,20 @@ hand-written-`.mjs` convention rastrillo already uses. |
| **No-JS visitors cannot sign up (ruling 2).** This session recommended a |
| fallback; Paul chose otherwise with the trade-off stated. The mechanism must |
| -be the workable one: **the form renders with its submit button `disabled` and |
| -the explanation inside `<noscript>`; the module removes `disabled` once |
| -loaded.** JavaScript cannot enable a control inside `<noscript>`, so the |
| -earlier phrasing was unbuildable. This ordering also fails safe — a module |
| -that throws leaves the honest disabled state rather than a live-looking form |
| -that does nothing. |
| +be the workable one: **the form renders with its submit button `disabled`, and |
| +the module removes `disabled` once loaded.** JavaScript cannot enable a |
| +control inside `<noscript>`, so an earlier phrasing that put the live control |
| +there was unbuildable. This ordering also fails safe — a module that throws |
| +leaves the button disabled rather than live and inert. |
| + |
| +**And the page does not say why.** An earlier draft of this section required |
| +an explanation in `<noscript>`, on the grounds that a visitor who cannot join |
| +should at least hear it from us. Paul removed it on 2026-09-02 after copy |
| +review — proof of work is our problem, not theirs, and a paragraph about bots |
| +and arithmetic is noise on a page whose whole job is asking someone a question |
| +about themselves. The honest consequence: a no-JS visitor meets a disabled |
| +button and no reason. It is a dead end rather than a deception, which is the |
| +line that matters, and it is why the button must never render enabled. |
| --- |
diff --git a/internal/movement/app.go b/internal/movement/app.go| index f1c00b8..61a5114 100644 |
| --- a/internal/movement/app.go |
| +++ b/internal/movement/app.go |
| @@ -76,9 +76,11 @@ func App(d *db.DB, env Env) (*http.ServeMux, error) { |
| // or forget in a form. |
| r.Use(csrf.Protect(env.Origin)) |
| r.Get("/", a.index) |
| - // Mounted by internal stance key. The reader-facing slugs are copy, and |
| - // copy has not been through review yet. |
| - r.Get("/s/{stance}", a.stancePage) |
| + // One route per reviewed slug rather than a wildcard, so an unknown path |
| + // stays a 404 from the router instead of reaching a handler. |
| + for _, s := range Stances { |
| + r.Get("/"+s.Slug(), a.stancePage(s)) |
| + } |
| r.Post("/signup", a.handleSignup) |
| r.Get("/confirm", a.handleConfirmPage) |
| r.Post("/confirm", a.handleConfirm) |
diff --git a/internal/movement/copy.go b/internal/movement/copy.go| index fb4d70d..f7de2ff 100644 |
| --- a/internal/movement/copy.go |
| +++ b/internal/movement/copy.go |
| @@ -4,87 +4,76 @@ import ( |
| "fmt" |
| "net/url" |
| "sort" |
| + "strings" |
| ) |
| // This app is its copy. The words are the mechanism that sorts people into |
| -// buckets, not decoration on top of one — so they go through the copy-review |
| -// gate before they reach a reader, and until they have, they live here as |
| -// marked placeholders. |
| +// buckets, not decoration on top of one — so every one of them went through |
| +// review with Paul before it reached this file, and what is here is his text, |
| +// applied verbatim. |
| // |
| -// Holding them in a table rather than in markup is what makes the whole |
| -// engine buildable and canary-reviewable before copy review has happened. It |
| -// also means unreviewed words cannot reach production by being quietly typed |
| -// into a template: CheckPlaceholders refuses the canonical host while any |
| -// remain. |
| +// Holding them in a table rather than in markup is what let the whole engine |
| +// be built and canary-reviewed before the words existed. The placeholder |
| +// machinery below stays for the same reason next time. |
| type entry struct { |
| text string |
| - // placeholder marks copy that has not been through review. Plan 2 |
| - // replaces the text and clears the flag, one id at a time. |
| + // placeholder marks copy that has not been through review. Everything |
| + // here has, so nothing sets it today — it exists for the next string |
| + // somebody adds. |
| placeholder bool |
| } |
| var table = map[string]entry{ |
| - // The landing page and the three choices. Slugs and labels are settled |
| - // at copy review; the third label especially, because "loves AI, hasn't |
| - // considered the ethics" describes a real group that will never |
| - // self-select under that description. |
| - "index.headline": {"PLACEHOLDER headline", true}, |
| - "index.lede": {"PLACEHOLDER lede", true}, |
| - "index.link": {"PLACEHOLDER link to carlosframework.com", true}, |
| - |
| - // Keyed by the internal stance key, so a template can look one up from |
| - // the value it already holds. The reader-facing slugs are a separate |
| - // question and are still open. |
| - "stance.never.label": {"PLACEHOLDER never label", true}, |
| - "stance.curious.label": {"PLACEHOLDER curious label", true}, |
| - "stance.enthusiast.label": {"PLACEHOLDER enthusiast label", true}, |
| - "stance.never.message": {"PLACEHOLDER never message", true}, |
| - "stance.curious.message": {"PLACEHOLDER curious message", true}, |
| - "stance.enthusiast.message": {"PLACEHOLDER enthusiast message", true}, |
| + // The landing page and the three choices. |
| + "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}, |
| + "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": {"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}, |
| // The form. |
| - "form.email": {"PLACEHOLDER email label", true}, |
| - "form.background": {"PLACEHOLDER background label", true}, |
| - "form.submit": {"PLACEHOLDER submit", true}, |
| - "form.working": {"PLACEHOLDER working", true}, |
| - "form.nojs": {"PLACEHOLDER javascript is required to sign up", true}, |
| - "form.bademail": {"PLACEHOLDER that address does not look right", true}, |
| - "form.honeypot": {"PLACEHOLDER leave this field empty", true}, |
| + "form.email": {"Your email address", false}, |
| + "form.background": {"Why are you interested, and how could you help?", false}, |
| + "form.submit": {"Sign up", false}, |
| + "form.working": {"Working…", false}, |
| + "form.bademail": {"That doesn't look like an email address.", false}, |
| + "form.honeypot": {"Leave this field empty", false}, |
| - // The one page every outcome renders. Its wording is load-bearing: it |
| - // is shown to an unknown address, a pending one and a confirmed one |
| - // alike, so it cannot say anything that would only be true of one. |
| - "sent.title": {"PLACEHOLDER check your inbox", true}, |
| - "sent.body": {"PLACEHOLDER check your inbox body", true}, |
| + // The one page every outcome renders. Its wording is load-bearing: it is |
| + // shown to an unknown address, a pending one and a confirmed one alike, so |
| + // it cannot say anything that would only be true of one. |
| + "sent.title": {"Check your inbox", false}, |
| + "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}, |
| // Confirmation. |
| - "confirm.title": {"PLACEHOLDER confirm", true}, |
| - "confirm.button": {"PLACEHOLDER confirm button", true}, |
| - "confirm.done": {"PLACEHOLDER you are on the list", true}, |
| - "confirm.dead": {"PLACEHOLDER that link is no longer valid", true}, |
| + "confirm.title": {"One more click", false}, |
| + "confirm.button": {"Confirm my email address", false}, |
| + "confirm.done": {"You're on the list.", false}, |
| + "confirm.dead": {"That link has expired or has already been used. Signing up again will send you a fresh one.", false}, |
| // Mail. |
| - "mail.confirm.subject": {"PLACEHOLDER confirmation subject", true}, |
| - "mail.confirm.body": {"PLACEHOLDER confirmation body", true}, |
| + "mail.confirm.subject": {"Confirm your email address", false}, |
| + "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}, |
| // Admin. |
| - "signin.title": {"PLACEHOLDER sign in", true}, |
| - "signin.sent": {"PLACEHOLDER check your inbox for a link", true}, |
| - "signin.submit": {"PLACEHOLDER send me a link", true}, |
| - "admin.title": {"PLACEHOLDER admin", true}, |
| - "admin.confirmed": {"PLACEHOLDER confirmed", true}, |
| - "admin.pending": {"PLACEHOLDER pending", true}, |
| - "admin.export": {"PLACEHOLDER download csv", true}, |
| - "admin.empty": {"PLACEHOLDER nobody yet", true}, |
| - "admin.signout": {"PLACEHOLDER sign out", true}, |
| - "admin.col.email": {"PLACEHOLDER email", true}, |
| - "admin.col.stance": {"PLACEHOLDER stance", true}, |
| - "admin.col.when": {"PLACEHOLDER confirmed on", true}, |
| - "admin.col.background": {"PLACEHOLDER background", true}, |
| - |
| - // Not a placeholder: this one exists to say that the others are. |
| - "banner.placeholder": {"This build carries unreviewed placeholder copy.", false}, |
| + "signin.title": {"Sign in", false}, |
| + "signin.submit": {"Send me a link", false}, |
| + "signin.sent": {"If that address can sign in, there's a link on its way to it.", false}, |
| + "admin.title": {"Signups", false}, |
| + "admin.confirmed": {"confirmed", false}, |
| + "admin.pending": {"waiting to confirm", false}, |
| + "admin.export": {"Download CSV", false}, |
| + "admin.empty": {"Nobody has confirmed yet.", false}, |
| + "admin.signout": {"Sign out", false}, |
| + "admin.col.email": {"Email", false}, |
| + "admin.col.stance": {"Stance", false}, |
| + "admin.col.when": {"Confirmed", false}, |
| + "admin.col.background": {"Background", false}, |
| } |
| // T resolves a copy id. |
| @@ -100,8 +89,32 @@ func T(id string) string { |
| return e.text |
| } |
| -// Placeholders lists the ids still awaiting copy review, sorted so the |
| -// output is stable enough to diff between builds. |
| +// CopyIDs lists every id in the table, so a test can check that nothing in |
| +// here shipped without going through review. |
| +func CopyIDs() []string { |
| + ids := make([]string, 0, len(table)) |
| + for id := range table { |
| + ids = append(ids, id) |
| + } |
| + sort.Strings(ids) |
| + return ids |
| +} |
| + |
| +// Paragraphs splits a string on blank lines so a template can render each as |
| +// its own paragraph. Copy is written the way a person writes it, with the |
| +// breaks meant; without this they collapse into one block of text. |
| +func Paragraphs(id string) []string { |
| + var out []string |
| + for _, p := range strings.Split(T(id), "\n\n") { |
| + if p = strings.TrimSpace(p); p != "" { |
| + out = append(out, p) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Placeholders lists the ids still awaiting copy review, sorted so the output |
| +// is stable enough to diff between builds. |
| func Placeholders() []string { |
| ids := make([]string, 0, len(table)) |
| for id, e := range table { |
| @@ -118,9 +131,9 @@ func Placeholders() []string { |
| // |
| // The asymmetry is the point. The shipping cadence reviews a branch on a |
| // deployed canary, and a canary serves the same binary production would — so |
| -// a build-time refusal would make plan 1 impossible to review, while no |
| -// refusal at all would let unreviewed words reach readers. Checking the |
| -// origin at boot is what separates the two. |
| +// a build-time refusal would make an engine-only branch impossible to review, |
| +// while no refusal at all would let unreviewed words reach readers. Checking |
| +// the origin at boot is what separates the two. |
| func CheckPlaceholders(origin, canonicalHost string) error { |
| left := Placeholders() |
| if len(left) == 0 { |
diff --git a/internal/movement/copy_test.go b/internal/movement/copy_test.go| index 5c33a6b..7ea2667 100644 |
| --- a/internal/movement/copy_test.go |
| +++ b/internal/movement/copy_test.go |
| @@ -42,7 +42,7 @@ func TestEveryReferencedCopyIDResolves(t *testing.T) { |
| "index.headline", "index.lede", |
| "sent.title", "sent.body", |
| "confirm.title", "confirm.button", "confirm.done", "confirm.dead", |
| - "form.email", "form.background", "form.submit", "form.nojs", |
| + "form.email", "form.background", "form.submit", |
| "mail.confirm.subject", "mail.confirm.body", |
| } { |
| func() { |
diff --git a/internal/movement/handlers.go b/internal/movement/handlers.go| index 0a81897..44e7500 100644 |
| --- a/internal/movement/handlers.go |
| +++ b/internal/movement/handlers.go |
| @@ -28,6 +28,22 @@ type app struct { |
| auth *auth.Auth |
| } |
| +// choice is one of the three positions as the landing page offers it. |
| +type choice struct { |
| + Slug string |
| + Label string |
| +} |
| + |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| - render(w, "index", nil) |
| + choices := make([]choice, 0, len(Stances)) |
| + for _, s := range Stances { |
| + choices = append(choices, choice{ |
| + Slug: s.Slug(), |
| + Label: T("stance." + string(s) + ".label"), |
| + }) |
| + } |
| + render(w, "index", map[string]any{ |
| + "Lede": Paragraphs("index.lede"), |
| + "Choices": choices, |
| + }) |
| } |
diff --git a/internal/movement/models.go b/internal/movement/models.go| index 01e89c2..58172b4 100644 |
| --- a/internal/movement/models.go |
| +++ b/internal/movement/models.go |
| @@ -28,6 +28,32 @@ const ( |
| // Stances is the closed set, in the order the landing page offers them. |
| var Stances = []Stance{StanceNever, StanceCurious, StanceEnthusiast} |
| +// Slugs are the web addresses people land on and share, reviewed alongside |
| +// the rest of the copy. They are separate from the stored keys on purpose: |
| +// a slug is copy and copy changes, while the value in the database has to |
| +// stay put or every stored row means something different afterwards. |
| +var slugs = map[string]Stance{ |
| + "no-ai": StanceNever, |
| + "uneasy": StanceCurious, |
| + "all-in": StanceEnthusiast, |
| +} |
| + |
| +// Slug returns the address for a stance. |
| +func (s Stance) Slug() string { |
| + for slug, k := range slugs { |
| + if k == s { |
| + return slug |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// StanceForSlug resolves an incoming address to a stored key. |
| +func StanceForSlug(slug string) (Stance, bool) { |
| + s, ok := slugs[slug] |
| + return s, ok |
| +} |
| + |
| // ValidStance reports whether s is one of the three. Anything else renders |
| // the same page as every other rejection — a made-up stance is a submission |
| // we refuse, not an error we explain. |
diff --git a/internal/movement/pages.go b/internal/movement/pages.go| index 8cf47d3..7ccf34c 100644 |
| --- a/internal/movement/pages.go |
| +++ b/internal/movement/pages.go |
| @@ -3,8 +3,6 @@ package movement |
| import ( |
| "net/http" |
| "time" |
| - |
| - "github.com/go-chi/chi/v5" |
| ) |
| // stanceData is what a stance page needs to render its form. |
| @@ -14,7 +12,11 @@ import ( |
| // view would buy nothing and would turn any crawler into a stream of |
| // serialised writes against the one SQLite writer every signup needs. |
| type stanceData struct { |
| - Stance string |
| + Stance string |
| + // 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 |
| @@ -22,20 +24,20 @@ type stanceData struct { |
| // stancePage renders one of the three positions and the form beneath it. |
| // |
| -// Routes are mounted by internal stance key for now. The reader-facing slugs |
| -// are copy, and copy has not been through review yet. |
| -func (a *app) stancePage(w http.ResponseWriter, r *http.Request) { |
| - stance := chi.URLParam(r, "stance") |
| - if !ValidStance(stance) { |
| - http.NotFound(w, r) |
| - return |
| +// Addressed by reviewed slug — /no-ai, /uneasy, /all-in — while the value |
| +// stored against a signup stays the internal key. Anyone who shares a link |
| +// shares the slug, so it is copy; the stored key has to outlive any wording |
| +// change or every row already written means something different afterwards. |
| +func (a *app) stancePage(stance Stance) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + a.renderStance(w, stance, "") |
| } |
| - a.renderStance(w, Stance(stance), "") |
| } |
| func (a *app) renderStance(w http.ResponseWriter, stance Stance, errMsg string) { |
| render(w, "stance", stanceData{ |
| Stance: string(stance), |
| + Message: Paragraphs("stance." + string(stance) + ".message"), |
| Challenge: NewChallenge(a.challengeKey, time.Now(), DefaultDifficulty), |
| Honeypot: honeypotField, |
| Error: errMsg, |
diff --git a/internal/movement/templates/index.html b/internal/movement/templates/index.html| index c354740..c564a88 100644 |
| --- a/internal/movement/templates/index.html |
| +++ b/internal/movement/templates/index.html |
| @@ -1,5 +1,15 @@ |
| {{define "title"}}{{say "index.headline"}}{{end}} |
| {{define "content"}} |
| <h1 class="rst-page-title">{{say "index.headline"}}</h1> |
| -<p>{{say "index.lede"}}</p> |
| +{{range .Lede}}<p>{{.}}</p>{{end}} |
| + |
| +<p><a href="https://carlosframework.com">{{say "index.link"}}</a></p> |
| + |
| +{{/* The three choices. Picking one is the first thing anyone does here, and |
| + the sorting is the product — so they are the page, not a widget on it. */}} |
| +<ul class="rst-list"> |
| + {{range .Choices}} |
| + <li><a href="/{{.Slug}}">{{.Label}}</a></li> |
| + {{end}} |
| +</ul> |
| {{end}} |
diff --git a/internal/movement/templates/stance.html b/internal/movement/templates/stance.html| index ab65a67..c50be48 100644 |
| --- a/internal/movement/templates/stance.html |
| +++ b/internal/movement/templates/stance.html |
| @@ -2,7 +2,7 @@ |
| {{define "head"}}<script type="module" src="{{asset "static/pow.js"}}"></script>{{end}} |
| {{define "content"}} |
| <h1 class="rst-page-title">{{say (printf "stance.%s.label" .Stance)}}</h1> |
| -<p>{{say (printf "stance.%s.message" .Stance)}}</p> |
| +{{range .Message}}<p>{{.}}</p>{{end}} |
| {{if .Error}}<p rst-callout="warn" role="alert">{{.Error}}</p>{{end}} |
| @@ -40,19 +40,18 @@ |
| <textarea id="background" name="background" rows="5" maxlength="2000"></textarea> |
| </label> |
| - {{/* Rendered disabled, and the module enables it once loaded. The other |
| - way round is unbuildable — script cannot enable a control inside |
| - <noscript> — and this ordering fails safe: a module that throws leaves |
| - the honest disabled state rather than a form that looks live and does |
| - nothing. */}} |
| + {{/* Rendered disabled, and the module enables it once loaded. The other way |
| + round is unbuildable — script cannot enable a control inside <noscript> |
| + — and this ordering fails safe: a module that throws leaves the button |
| + disabled rather than live and inert. |
| + |
| + There is deliberately no explanation beside it. Paul's call, after copy |
| + review: why the form needs JavaScript is our problem, not a visitor's, |
| + and the page is not the place to explain proof of work. */}} |
| <button type="submit" disabled data-pow-submit |
| data-label="{{say "form.submit"}}" |
| data-working-label="{{say "form.working"}}">{{say "form.submit"}}</button> |
| <p hidden data-pow-status></p> |
| - |
| - <noscript> |
| - <p rst-callout="warn">{{say "form.nojs"}}</p> |
| - </noscript> |
| </form> |
| {{end}} |
diff --git a/internal/movementtest/copy_test.go b/internal/movementtest/copy_test.go| new file mode 100644 |
| index 0000000..2bb5546 |
| --- /dev/null |
| +++ b/internal/movementtest/copy_test.go |
| @@ -0,0 +1,119 @@ |
| +package movementtest |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + movement "amadan.net/carlos/movement/internal/movement" |
| +) |
| + |
| +// The copy in this app is reviewed by Paul, string by string, and applied |
| +// verbatim. This test is what stops it drifting afterwards. |
| +// |
| +// Without it, "applied verbatim" is a claim about one afternoon. A later |
| +// session fixing a typo, tightening a sentence or making a heading |
| +// "consistent" would be quietly overwriting somebody's reviewed words, and |
| +// 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" |
| + |
| +type reviewedString struct { |
| + ID string `json:"id"` |
| + Text string `json:"text"` |
| +} |
| + |
| +type reviewOutcome struct { |
| + Action string `json:"action"` |
| + Strings []reviewedString `json:"strings"` |
| +} |
| + |
| +// notInTheTable are ids that went through review but deliberately do not |
| +// appear in the copy table. Each one needs a reason here, so that dropping a |
| +// reviewed string is a decision on the record rather than an omission. |
| +var notInTheTable = map[string]string{ |
| + // Paul, after submitting: "Lose the JS required message ... that's an |
| + // irrelevant implementation detail." The form still needs JavaScript; the |
| + // page no longer explains why. |
| + "form.nojs": "removed on request after review", |
| + |
| + // These three are web addresses rather than words on a page. They are |
| + // reviewed copy, but they live in the router. |
| + "slug.never": "routing, not display copy", |
| + "slug.curious": "routing, not display copy", |
| + "slug.enthusiast": "routing, not display copy", |
| +} |
| + |
| +// editorialPrefix arrived at the front of one submitted string. It reads as a |
| +// note to the person applying the copy rather than words for a visitor, so it |
| +// was stripped and the question put back to Paul rather than answered by |
| +// guessing. If he wants the disclosure shown, it becomes copy of its own |
| +// rather than a prefix on a message. |
| +const editorialPrefix = "This was entirely AI-generated. Human editor leaving as-is:" |
| + |
| +func TestEveryStringMatchesTheApprovedReview(t *testing.T) { |
| + raw, err := os.ReadFile(filepath.FromSlash(reviewResult)) |
| + if err != nil { |
| + t.Fatalf("no review record to check against: %v", err) |
| + } |
| + var out reviewOutcome |
| + if err := json.Unmarshal(raw, &out); err != nil { |
| + t.Fatalf("unreadable review record: %v", err) |
| + } |
| + if out.Action != "approve" { |
| + t.Fatalf("the review record says %q, not approve — nothing should have shipped", out.Action) |
| + } |
| + |
| + checked := 0 |
| + for _, s := range out.Strings { |
| + if why, skipped := notInTheTable[s.ID]; skipped { |
| + t.Logf("%s: not in the table (%s)", s.ID, why) |
| + continue |
| + } |
| + |
| + want := s.Text |
| + if rest, found := strings.CutPrefix(want, editorialPrefix); found { |
| + want = strings.TrimLeft(rest, "\n ") |
| + } |
| + |
| + got := movement.T(s.ID) |
| + if got != want { |
| + t.Errorf("%s does not match what was approved.\n approved: %q\n shipped : %q", |
| + s.ID, want, got) |
| + } |
| + checked++ |
| + } |
| + |
| + if checked == 0 { |
| + t.Fatal("the review record matched nothing; the ids have drifted apart") |
| + } |
| + t.Logf("%d reviewed strings match", checked) |
| +} |
| + |
| +// A string in the table that nobody reviewed is the failure this whole gate |
| +// exists to prevent, so it is worth catching directly rather than trusting |
| +// that the next person remembers. |
| +func TestNoStringShippedWithoutReview(t *testing.T) { |
| + raw, err := os.ReadFile(filepath.FromSlash(reviewResult)) |
| + if err != nil { |
| + t.Fatalf("no review record: %v", err) |
| + } |
| + var out reviewOutcome |
| + if err := json.Unmarshal(raw, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reviewed := map[string]bool{} |
| + for _, s := range out.Strings { |
| + reviewed[s.ID] = true |
| + } |
| + |
| + for _, id := range movement.CopyIDs() { |
| + if !reviewed[id] { |
| + t.Errorf("%q is in the copy table but was never reviewed", id) |
| + } |
| + } |
| +} |
diff --git a/internal/movementtest/signup_test.go b/internal/movementtest/signup_test.go| index c9bb778..cf8c38e 100644 |
| --- a/internal/movementtest/signup_test.go |
| +++ b/internal/movementtest/signup_test.go |
| @@ -1,6 +1,7 @@ |
| package movementtest |
| import ( |
| + "html/template" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| @@ -176,8 +177,8 @@ func TestHoneypotLooksLikeSuccessAndStoresNothing(t *testing.T) { |
| func TestStancePageWritesNothing(t *testing.T) { |
| h := newApp(t) |
| for i := 0; i < 5; i++ { |
| - if rec := get(t, h, "/s/curious"); rec.Code != http.StatusOK { |
| - t.Fatalf("GET /s/curious: %d", rec.Code) |
| + if rec := get(t, h, "/uneasy"); rec.Code != http.StatusOK { |
| + t.Fatalf("GET /uneasy: %d", rec.Code) |
| } |
| } |
| if got := countNonces(t, h); got != 0 { |
| @@ -270,7 +271,13 @@ func TestMalformedAddressIsToldSo(t *testing.T) { |
| if rec.Body.String() == sent { |
| t.Error("a typo should be reported, not silently swallowed") |
| } |
| - if !strings.Contains(rec.Body.String(), movement.T("form.bademail")) { |
| + // Compared escaped, because that is what reaches the page. The copy has an |
| + // apostrophe in it, and html/template renders that as ' — a raw |
| + // comparison fails on correct output, which is a test bug dressed as a |
| + // code bug. |
| + var escaped strings.Builder |
| + template.HTMLEscape(&escaped, []byte(movement.T("form.bademail"))) |
| + if !strings.Contains(rec.Body.String(), escaped.String()) { |
| t.Error("the malformed-address page should say what went wrong") |
| } |
| } |