carlos / movement Public

A name field, and the selector that would not have styled it

The rule was bound to input[type="email"], so the first text input added to
the form fell straight through to the browser's rounded default — a boxed
field on a page whose whole doctrine is zero radius and ruled underlines.
Nothing caught it, because nothing was looking at computed style.

There is a browser test now that reads the actual radius and border of all
three fields. A stylesheet check would have passed a selector that names one
input type, which is exactly the trap the next added field walks into.

The column is an additive ALTER, so the live database keeps its rows. The
name is required — a guess, and one Paul can overrule — and a missing one is
told plainly rather than swallowed, which leaks nothing: it is a fact about
the form just submitted, not about any address's state.

It is the first admin column and the first CSV column, and it gets the same
bounds, control stripping and formula defusing as anything else somebody
types. A name also changes what a row IS: address plus stance was a position
attached to somebody contactable, and a name makes them identifiable. That
reasoning is recorded beside the field.

Four strings await review, so production is shut again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell dc1f2de4aee38689cbc73255f06c59bd147cd7d3 parent 6ce317b
15 files changed, +215 −55
  • copy-review/result.json +0 −1
  • copy-review/server.log +2 −4
  • copy-review/strings.json +23 −39
  • internal/movement/admin.go +4 −2
  • internal/movement/copy.go +6 −0
  • internal/movement/migrations/0003_alter_signups.sql +2 −0
  • internal/movement/migrations/schema.sql +1 −1
  • internal/movement/models.go +6 −0
  • internal/movement/signup.go +18 −6
  • internal/movement/static/bill.css +2 −1
  • internal/movement/templates/admin.html +3 −1
  • internal/movement/templates/stance.html +6 −0
  • internal/movementtest/fields_test.go +43 −0
  • internal/movementtest/name_test.go +98 −0
  • internal/movementtest/signup_test.go +1 −0
diff --git a/copy-review/result.json b/copy-review/result.json
deleted file mode 100644
index 544bd2e..0000000
--- a/copy-review/result.json
+++ /dev/null
@@ -1 +0,0 @@
-{"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
index fdb48f0..abcb850 100644
--- a/copy-review/server.log
+++ b/copy-review/server.log
@@ -1,5 +1,3 @@
-REVIEW_URL: http://docs-t1.tail5fd3ba.ts.net:42745
-REVIEW_URL_FALLBACK: http://100.119.180.57:42745
+REVIEW_URL: http://docs-t1.tail5fd3ba.ts.net:40617
+REVIEW_URL_FALLBACK: http://100.119.180.57:40617
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 13ec314..56fddf0 100644
--- a/copy-review/strings.json
+++ b/copy-review/strings.json
@@ -1,50 +1,34 @@
[
{
- "id": "index.headline",
- "section": "Landing page",
- "label": "Headline",
- "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 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": "tear.instruction",
- "section": "Landing page",
- "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": "form.name",
+ "section": "Signup form",
+ "label": "Name field label",
+ "text": "Your name",
+ "context": "Labels the first field on the signup form, above the email address. It is required — you are recruiting people into a movement, so a nameless row is one you cannot do much with. Say if you would rather it were optional and I will loosen it.",
+ "notes": "The field caps at 120 characters and accepts any script, so it does not break names that English-language forms usually break."
},
{
- "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": "form.name.placeholder",
+ "section": "Signup form",
+ "label": "Placeholder in the name field",
+ "text": "What should we call you?",
+ "context": "Grey text inside the name box before anyone types. The fields are ruled underlines rather than boxes, so the placeholder is what makes it read as somewhere to type at all.",
+ "notes": "It vanishes on typing, so it cannot carry anything essential. This one does a second job: \"what should we call you\" invites a first name or a handle rather than demanding a legal name."
},
{
- "id": "form.email.placeholder",
+ "id": "form.badname",
"section": "Signup form",
- "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."
+ "label": "Error when the name is missing",
+ "text": "We need something to call you.",
+ "context": "Shown above the form when somebody submits without a name. It is one of only two visible errors on the whole signup path — everything else renders the ordinary confirmation page, because saying more would let a stranger work out who is already on the list.",
+ "notes": "Saying this leaks nothing: it is a fact about the form just submitted, not about any address's state."
},
{
- "id": "form.background.placeholder",
- "section": "Signup form",
- "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."
+ "id": "admin.col.name",
+ "section": "Admin",
+ "label": "Table column — name",
+ "text": "Name",
+ "context": "Column heading in the list of confirmed signups. Only you see it. The name is the first column now, ahead of the email address, since it is the thing you would actually read down.",
+ "notes": ""
}
]
\ No newline at end of file
diff --git a/internal/movement/admin.go b/internal/movement/admin.go
index 601ef59..c70c9fb 100644
--- a/internal/movement/admin.go
+++ b/internal/movement/admin.go
@@ -117,6 +117,7 @@ func (a *app) requireAdmin(next http.Handler) http.Handler {
}
type adminRow struct {
+ Name string
Email string
Stance string
Background string
@@ -150,7 +151,7 @@ func (a *app) adminIndex(w http.ResponseWriter, r *http.Request) {
if row.ConfirmedAt != nil {
when = *row.ConfirmedAt
}
- out = append(out, adminRow{row.Email, row.Stance, row.Background, when})
+ out = append(out, adminRow{row.Name, row.Email, row.Stance, row.Background, when})
}
render(w, "admin", map[string]any{
@@ -185,13 +186,14 @@ func (a *app) adminExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="movement.csv"`)
cw := csv.NewWriter(w)
- _ = cw.Write([]string{"email", "stance", "confirmed_at", "background"})
+ _ = cw.Write([]string{"name", "email", "stance", "confirmed_at", "background"})
for _, row := range rows {
when := ""
if row.ConfirmedAt != nil {
when = row.ConfirmedAt.UTC().Format(time.RFC3339)
}
_ = cw.Write([]string{
+ csvSafe(row.Name),
csvSafe(row.Email),
csvSafe(row.Stance),
when,
diff --git a/internal/movement/copy.go b/internal/movement/copy.go
index 937e04b..f438851 100644
--- a/internal/movement/copy.go
+++ b/internal/movement/copy.go
@@ -55,6 +55,11 @@ var table = map[string]entry{
// refuses to serve them.
"tear.instruction": {"Take the one that's yours.", false},
+ // Awaiting review: the name field, added 2026-09-03.
+ "form.name": {"PLACEHOLDER name label", true},
+ "form.name.placeholder": {"PLACEHOLDER name placeholder", true},
+ "form.badname": {"PLACEHOLDER we need something to call you", true},
+
// The form.
"form.email": {"Your email address", false},
"form.background": {"Why are you interested, and how could you help?", false},
@@ -89,6 +94,7 @@ var table = map[string]entry{
"admin.export": {"Download CSV", false},
"admin.empty": {"Nobody has confirmed yet.", false},
"admin.signout": {"Sign out", false},
+ "admin.col.name": {"PLACEHOLDER name column", true},
"admin.col.email": {"Email", false},
"admin.col.stance": {"Stance", false},
"admin.col.when": {"Confirmed", false},
diff --git a/internal/movement/migrations/0003_alter_signups.sql b/internal/movement/migrations/0003_alter_signups.sql
new file mode 100644
index 0000000..f1c28db
--- /dev/null
+++ b/internal/movement/migrations/0003_alter_signups.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `signups` ADD `name` text;
+
diff --git a/internal/movement/migrations/schema.sql b/internal/movement/migrations/schema.sql
index f150f7b..0217d8b 100644
--- a/internal/movement/migrations/schema.sql
+++ b/internal/movement/migrations/schema.sql
@@ -3,7 +3,7 @@
CREATE TABLE `mail_counters` (`hour_bucket` text,`count` integer,PRIMARY KEY (`hour_bucket`));
-CREATE TABLE `signups` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`stance` text,`background` text,`confirmed` numeric,`confirmed_at` datetime,`token_hash` blob,`token_expiry` datetime,`last_mailed_at` datetime,`mail_count` integer,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `signups` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`stance` text,`background` text,`confirmed` numeric,`confirmed_at` datetime,`token_hash` blob,`token_expiry` datetime,`last_mailed_at` datetime,`mail_count` integer,`created_at` datetime,`updated_at` datetime, `name` text);
CREATE TABLE `spent_nonces` (`nonce` text,`expires_at` datetime,PRIMARY KEY (`nonce`));
diff --git a/internal/movement/models.go b/internal/movement/models.go
index 58172b4..fa3184a 100644
--- a/internal/movement/models.go
+++ b/internal/movement/models.go
@@ -68,6 +68,11 @@ func ValidStance(s string) bool {
// Signup is one person's answer.
//
+// Name joined on 2026-09-03. It widens what a row is: an address and a
+// stance was already a stated position attached to a contactable person, and
+// a name makes that person identifiable rather than merely reachable. Every
+// rule about the sensitive pair applies to it, and it never reaches a log.
+//
// There is no owner column and no User model, because nothing here is owned:
// the public surface writes rows nobody is signed in for, and the admin view
// reads all of them. Worth naming, because rastrillo's most expensive trap is
@@ -79,6 +84,7 @@ type Signup struct {
ID int64
Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe
+ Name string // what they call themselves; capped and stripped
Stance string
Background string // capped and stripped of control characters on the way in
diff --git a/internal/movement/signup.go b/internal/movement/signup.go
index a188746..1ba665f 100644
--- a/internal/movement/signup.go
+++ b/internal/movement/signup.go
@@ -20,6 +20,10 @@ import (
// failure the <noscript> rule exists to prevent.
const honeypotField = "hp"
+// maxName caps the name field. Long enough for anybody's actual name,
+// including the ones English-speaking forms usually break.
+const maxName = 120
+
// maxBackground caps the free-text field. Somebody with more to say than this
// can say it in a reply once they are on the list.
const maxBackground = 2000
@@ -85,7 +89,13 @@ func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
return
}
- background := clampBackground(r.PostFormValue("background"))
+ background := clampText(r.PostFormValue("background"), maxBackground)
+ name := clampText(r.PostFormValue("name"), maxName)
+ if name == "" {
+ Rejected(ReasonBounds)
+ a.renderStance(w, Stance(stance), T("form.badname"))
+ return
+ }
if !VerifyPow(c.Nonce, email, r.PostFormValue("counter"), c.Difficulty) {
Rejected(ReasonPowShort)
@@ -119,6 +129,7 @@ func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
token, hash := mintToken()
row = Signup{
Email: email,
+ Name: name,
Stance: stance,
Background: background,
TokenHash: hash,
@@ -149,6 +160,7 @@ func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
default:
token, hash := mintToken()
+ row.Name = name
row.Stance = stance
row.Background = background
row.TokenHash = hash
@@ -162,7 +174,7 @@ func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
// Named columns, and the budget columns are not among the ones a
// resubmission may reset. A counter somebody can clear by filling
// the form again is not a budget.
- cols := []string{"Stance", "Background", "TokenHash", "TokenExpiry", "UpdatedAt"}
+ cols := []string{"Name", "Stance", "Background", "TokenHash", "TokenExpiry", "UpdatedAt"}
if mailed {
cols = append(cols, "LastMailedAt", "MailCount")
}
@@ -238,9 +250,9 @@ func normalizeAddress(raw string) (string, bool) {
return NormalizeEmail(s), true
}
-// clampBackground truncates rather than refuses, and strips the control
-// characters that would otherwise reach a CSV cell or a log line.
-func clampBackground(raw string) string {
+// clampText truncates rather than refuses, and strips the control characters
+// that would otherwise reach a CSV cell, an SMTP header or a log line.
+func clampText(raw string, max int) string {
var b strings.Builder
runes := 0
for _, r := range raw {
@@ -251,7 +263,7 @@ func clampBackground(raw string) string {
continue
}
b.WriteRune(r)
- if runes++; runes >= maxBackground {
+ if runes++; runes >= max {
break
}
}
diff --git a/internal/movement/static/bill.css b/internal/movement/static/bill.css
index ac372e2..c795406 100644
--- a/internal/movement/static/bill.css
+++ b/internal/movement/static/bill.css
@@ -326,6 +326,7 @@ body {
margin-bottom: 0.35rem;
}
.field input[type="email"],
+.field input[type="text"],
.field textarea {
width: 100%;
box-sizing: border-box;
@@ -344,7 +345,7 @@ body {
padding: 0.55rem 0.7rem;
background: rgba(23, 20, 15, 0.05);
}
-.field input[type="email"]::placeholder,
+.field input::placeholder,
.field textarea::placeholder {
color: var(--bill-dim-bone);
}
diff --git a/internal/movement/templates/admin.html b/internal/movement/templates/admin.html
index 5fb2bdf..cb4b69c 100644
--- a/internal/movement/templates/admin.html
+++ b/internal/movement/templates/admin.html
@@ -21,6 +21,7 @@
<table class="ledger__table">
<thead>
<tr>
+ <th>{{say "admin.col.name"}}</th>
<th>{{say "admin.col.email"}}</th>
<th>{{say "admin.col.stance"}}</th>
<th>{{say "admin.col.when"}}</th>
@@ -30,13 +31,14 @@
<tbody>
{{range .Rows}}
<tr>
+ <td>{{.Name}}</td>
<td>{{.Email}}</td>
<td>{{say (printf "stance.%s.label" .Stance)}}</td>
<td>{{.When.Format "2006-01-02"}}</td>
<td>{{.Background}}</td>
</tr>
{{else}}
- <tr><td colspan="4">{{say "admin.empty"}}</td></tr>
+ <tr><td colspan="5">{{say "admin.empty"}}</td></tr>
{{end}}
</tbody>
</table>
diff --git a/internal/movement/templates/stance.html b/internal/movement/templates/stance.html
index 7f4ecce..2480ea0 100644
--- a/internal/movement/templates/stance.html
+++ b/internal/movement/templates/stance.html
@@ -46,6 +46,12 @@
tabindex="-1" autocomplete="off">
</div>
+ <label class="field" for="name">
+ <span>{{say "form.name"}}</span>
+ <input type="text" id="name" name="name" required maxlength="120"
+ autocomplete="name" placeholder="{{say "form.name.placeholder"}}">
+ </label>
+
<label class="field" for="email">
<span>{{say "form.email"}}</span>
<input type="email" id="email" name="email" required
diff --git a/internal/movementtest/fields_test.go b/internal/movementtest/fields_test.go
new file mode 100644
index 0000000..2cbb924
--- /dev/null
+++ b/internal/movementtest/fields_test.go
@@ -0,0 +1,43 @@
+//go:build browser
+
+package movementtest
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/chromedp/chromedp"
+)
+
+// Every field on the form is styled the same way, checked in a real browser
+// against computed style rather than by reading the stylesheet.
+//
+// This exists because the name field shipped unstyled: the rule was bound to
+// input[type="email"], so the first text input added to the form fell
+// straight through to the browser's rounded default and nothing noticed. A
+// selector that names one input type is a trap for the next field somebody
+// adds, and only the browser can say what actually applied.
+func TestEveryFieldIsRuledNotBoxed(t *testing.T) {
+ rig := powRig(t)
+ rig.Run(chromedp.Navigate(rig.Origin + "/uneasy"))
+
+ for _, sel := range []string{"#name", "#email", "#background"} {
+ var radius, bottomWidth string
+ rig.Run(
+ chromedp.Evaluate(fmt.Sprintf(
+ `getComputedStyle(document.querySelector(%q)).borderTopLeftRadius`, sel),
+ &radius),
+ chromedp.Evaluate(fmt.Sprintf(
+ `getComputedStyle(document.querySelector(%q)).borderBottomWidth`, sel),
+ &bottomWidth),
+ )
+ if radius != "0px" {
+ t.Errorf("%s has a corner radius of %s; this world has no rounded corners",
+ sel, radius)
+ }
+ if bottomWidth != "2px" {
+ t.Errorf("%s has a %s bottom rule; the fields are ruled underlines",
+ sel, bottomWidth)
+ }
+ }
+}
diff --git a/internal/movementtest/name_test.go b/internal/movementtest/name_test.go
new file mode 100644
index 0000000..7c245e8
--- /dev/null
+++ b/internal/movementtest/name_test.go
@@ -0,0 +1,98 @@
+package movementtest
+
+import (
+ "strings"
+ "testing"
+
+ movement "amadan.net/carlos/movement/internal/movement"
+)
+
+// A name is required, and a submission without one is told so rather than
+// silently dropped — the same treatment a malformed address gets, and for the
+// same reason: it is a property of what was just typed, not of any address's
+// state on the list, so saying it leaks nothing.
+func TestSignupNeedsAName(t *testing.T) {
+ h := newApp(t)
+
+ form := submission(t, "anon@example.com", "curious")
+ form.Set("name", " ")
+ rec := postSignup(t, h, form)
+
+ if got := countSignups(t, h, "anon@example.com"); got != 0 {
+ t.Errorf("a nameless submission wrote %d rows, want 0", got)
+ }
+ if !strings.Contains(rec.Body.String(), escapeForPage(movement.T("form.badname"))) {
+ t.Error("the page should say a name is needed")
+ }
+}
+
+func TestNameIsStoredAndShownToAdmin(t *testing.T) {
+ h := newAdminApp(t)
+
+ form := submission(t, "named@example.com", "curious")
+ form.Set("name", "Ada Lovelace")
+ postSignup(t, h, form)
+
+ if got := loadSignup(t, h, "named@example.com").Name; got != "Ada Lovelace" {
+ t.Errorf("stored name = %q", got)
+ }
+
+ confirmSignup(t, h, "named@example.com")
+ cookies := signInAsAdmin(t, h)
+
+ if !strings.Contains(getAs(t, h, "/admin", cookies).Body.String(), "Ada Lovelace") {
+ t.Error("the admin list does not show the name")
+ }
+ csv := getAs(t, h, "/admin/export.csv", cookies).Body.String()
+ if !strings.HasPrefix(csv, "name,email,stance") {
+ t.Errorf("the export's first column should be the name:\n%s", csv[:60])
+ }
+ if !strings.Contains(csv, "Ada Lovelace") {
+ t.Error("the name is missing from the export")
+ }
+}
+
+// The name reaches a CSV cell and an admin page, so it gets the same bounds
+// and the same formula-injection defusing as everything else somebody types.
+func TestNameIsBoundedAndDefused(t *testing.T) {
+ h := newAdminApp(t)
+
+ long := strings.Repeat("é", 400)
+ form := submission(t, "long@example.com", "curious")
+ form.Set("name", long)
+ postSignup(t, h, form)
+
+ if n := len([]rune(loadSignup(t, h, "long@example.com").Name)); n > 120 {
+ t.Errorf("stored name is %d runes; the cap is 120", n)
+ }
+
+ form = submission(t, "sneaky@example.com", "curious")
+ form.Set("name", "=cmd|'/c calc'!A1")
+ postSignup(t, h, form)
+ confirmSignup(t, h, "sneaky@example.com")
+
+ cookies := signInAsAdmin(t, h)
+ csv := getAs(t, h, "/admin/export.csv", cookies).Body.String()
+ if strings.Contains(csv, ",=cmd") || strings.Contains(csv, "\"=cmd") {
+ t.Errorf("a formula reached the export through the name field:\n%s", csv)
+ }
+}
+
+// A name is not a field the copy round has seen yet, so it must not be able
+// to reach production. This is the same gate the design was built behind.
+func TestTheNameFieldsAreStillAwaitingReview(t *testing.T) {
+ left := movement.Placeholders()
+ if len(left) == 0 {
+ t.Skip("the name copy has been reviewed; this gate has done its job")
+ }
+ if err := movement.CheckPlaceholders(
+ "https://"+movement.CanonicalHost, movement.CanonicalHost); err == nil {
+ t.Error("unreviewed copy would be served on the canonical host")
+ }
+}
+
+func escapeForPage(s string) string {
+ r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;",
+ `"`, "&#34;", "'", "&#39;")
+ return r.Replace(s)
+}
diff --git a/internal/movementtest/signup_test.go b/internal/movementtest/signup_test.go
index cf8c38e..f3148eb 100644
--- a/internal/movementtest/signup_test.go
+++ b/internal/movementtest/signup_test.go
@@ -46,6 +46,7 @@ func solved(t *testing.T, c movement.Challenge, email, stance string) url.Values
return url.Values{
"stance": {stance},
"email": {email},
+ "name": {"Alex Río"},
"background": {"why I am interested"},
"nonce": {c.Nonce},
"issued_at": {strconv.FormatInt(c.IssuedAt, 10)},