feat: every report string comes from copy-review/strings.json (draft)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 89261e724c5ca526101dfc251d9bc4239af74064 parent 793a453
7 files changed, +675 −85
  • copy-review/strings.json +547 −0
  • harness/apps.go +2 −2
  • harness/cmd/bakeoff/main.go +6 −2
  • harness/report/markdown.go +32 −2
  • harness/report/report.go +44 −58
  • harness/report/report.html.tmpl +21 −18
  • harness/report/report_test.go +23 −3
diff --git a/copy-review/strings.json b/copy-review/strings.json
new file mode 100644
index 0000000..85bf82c
--- /dev/null
+++ b/copy-review/strings.json
@@ -0,0 +1,547 @@
+[
+ {
+ "id": "page.title",
+ "section": "Top",
+ "label": "Page title",
+ "text": "One TodoMVC, nine languages, one platform",
+ "context": "The page heading and the browser tab title. It names what the page compares.",
+ "notes": "Keep short."
+ },
+ {
+ "id": "page.intro",
+ "section": "Top",
+ "label": "Introduction",
+ "text": "PROSE_PENDING",
+ "context": "The opening paragraphs under the heading. They say what was built, by whom, where it runs, and that the page compares rather than ranks.",
+ "notes": "Markdown: paragraphs, links, **bold**."
+ },
+ {
+ "id": "findings.heading",
+ "section": "Findings",
+ "label": "Heading",
+ "text": "What stood out",
+ "context": "Heading over a short list of the results worth knowing before reading the charts."
+ },
+ {
+ "id": "findings.body",
+ "section": "Findings",
+ "label": "Body",
+ "text": "PROSE_PENDING",
+ "context": "A handful of plain bullet points: the notable results, each with its number.",
+ "notes": "Markdown list."
+ },
+ {
+ "id": "glance.heading",
+ "section": "At a glance",
+ "label": "Heading",
+ "text": "At a glance",
+ "context": "Heading over the summary table, one row per language."
+ },
+ {
+ "id": "glance.col.setup",
+ "section": "At a glance",
+ "label": "Column: Setup",
+ "text": "Setup",
+ "context": "Column heading in the summary table. The language (and framework, for Go with Rastrillo) each row is about.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.tests",
+ "section": "At a glance",
+ "label": "Column: Tests (JS on · off)",
+ "text": "Tests (JS on · off)",
+ "context": "Column heading in the summary table. How many of the shared behaviour tests passed, with JavaScript on and with it off.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.first_byte",
+ "section": "At a glance",
+ "label": "Column: First byte",
+ "text": "First byte",
+ "context": "Column heading in the summary table. Median time to the first byte of the page, with an empty list.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.wake",
+ "section": "At a glance",
+ "label": "Column: Wake",
+ "text": "Wake",
+ "context": "Column heading in the summary table. Median time to the first byte when the app had gone to sleep.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.reads",
+ "section": "At a glance",
+ "label": "Column: Peak reads/s",
+ "text": "Peak reads/s",
+ "context": "Column heading in the summary table. The most page loads per second the app served under the load ramp.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.binary",
+ "section": "At a glance",
+ "label": "Column: Binary",
+ "text": "Binary",
+ "context": "Column heading in the summary table. Size of the arm64 file that ships.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.build",
+ "section": "At a glance",
+ "label": "Column: Cold / warm build",
+ "text": "Cold / warm build",
+ "context": "Column heading in the summary table. Build time with every library compiled from scratch, and with libraries already compiled.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.lines",
+ "section": "At a glance",
+ "label": "Column: Lines",
+ "text": "Lines",
+ "context": "Column heading in the summary table. Lines of the app's own code, tests excluded.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.deps",
+ "section": "At a glance",
+ "label": "Column: Deps",
+ "text": "Deps",
+ "context": "Column heading in the summary table. Number of third-party libraries the app uses directly.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.cost",
+ "section": "At a glance",
+ "label": "Column: Cost",
+ "text": "Cost",
+ "context": "Column heading in the summary table. What the AI build and fix rounds cost at list price.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "glance.col.readability",
+ "section": "At a glance",
+ "label": "Column: Readability",
+ "text": "Readability",
+ "context": "Column heading in the summary table. The blind readability score out of 10.",
+ "notes": "Short: a table header."
+ },
+ {
+ "id": "chart.ttfb_empty.title",
+ "section": "Charts",
+ "label": "Time to first byte, empty list: title",
+ "text": "Time to first byte, empty list",
+ "context": "Title of one chart. How quickly a first page arrives when a visitor has no todos.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.ttfb_empty.note",
+ "section": "Charts",
+ "label": "Time to first byte, empty list: note",
+ "text": "Median of 30 page loads from our test machine, each on a new connection, so it includes the network.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.ttfb_50.title",
+ "section": "Charts",
+ "label": "Time to first byte, 50 items: title",
+ "text": "Time to first byte, 50 items",
+ "context": "Title of one chart. The same measure with a realistic list.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.ttfb_50.note",
+ "section": "Charts",
+ "label": "Time to first byte, 50 items: note",
+ "text": "The same, with 50 todos on the list.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.wake.title",
+ "section": "Charts",
+ "label": "Wake from sleep: title",
+ "text": "Wake from sleep",
+ "context": "Title of one chart. Carloku puts idle apps to sleep; this is what the first visitor after a quiet spell waits.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.wake.note",
+ "section": "Charts",
+ "label": "Wake from sleep: note",
+ "text": "Time to the first byte when the app had gone to sleep after 15 idle minutes. Median of three.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.reads_peak.title",
+ "section": "Charts",
+ "label": "Peak page loads per second: title",
+ "text": "Peak page loads per second",
+ "context": "Title of one chart. Throughput for reads.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.reads_peak.note",
+ "section": "Charts",
+ "label": "Peak page loads per second: note",
+ "text": "Loading a 50-item list while doubling the number of simultaneous connections from 1, 10 seconds per step.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.reads_knee.title",
+ "section": "Charts",
+ "label": "Page loads: where it slows down: title",
+ "text": "Page loads: where it slows down",
+ "context": "Title of one chart. Where extra load starts adding waiting rather than throughput.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.reads_knee.note",
+ "section": "Charts",
+ "label": "Page loads: where it slows down: note",
+ "text": "The fewest simultaneous connections from which adding more never raised throughput by 10% or more.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.toggles_peak.title",
+ "section": "Charts",
+ "label": "Peak toggles per second: title",
+ "text": "Peak toggles per second",
+ "context": "Title of one chart. Throughput for writes.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.toggles_peak.note",
+ "section": "Charts",
+ "label": "Peak toggles per second: note",
+ "text": "Ticking one todo on and off, ramping the same way.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.toggles_knee.title",
+ "section": "Charts",
+ "label": "Toggles: where it slows down: title",
+ "text": "Toggles: where it slows down",
+ "context": "Title of one chart. Where writes stop scaling.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.toggles_knee.note",
+ "section": "Charts",
+ "label": "Toggles: where it slows down: note",
+ "text": "The same rule as for page loads.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.reads_after.title",
+ "section": "Charts",
+ "label": "Peak page loads per second, after the writes: title",
+ "text": "Peak page loads per second, after the writes",
+ "context": "Title of one chart. Whether heavy writing leaves the app slower to read.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.reads_after.note",
+ "section": "Charts",
+ "label": "Peak page loads per second, after the writes: note",
+ "text": "The read ramp again, straight after the toggle ramp, on the same database.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.binary.title",
+ "section": "Charts",
+ "label": "Binary size: title",
+ "text": "Binary size",
+ "context": "Title of one chart. How big the deployable is.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.binary.note",
+ "section": "Charts",
+ "label": "Binary size: note",
+ "text": "The linux-arm64 file that ships.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.build_cold.title",
+ "section": "Charts",
+ "label": "Cold build: title",
+ "text": "Cold build",
+ "context": "Title of one chart. Build time from scratch.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.build_cold.note",
+ "section": "Charts",
+ "label": "Cold build: note",
+ "text": "Every library compiled from already-downloaded sources. Median of three.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.build_warm.title",
+ "section": "Charts",
+ "label": "Warm build: title",
+ "text": "Warm build",
+ "context": "Title of one chart. Build time for a routine rebuild.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.build_warm.note",
+ "section": "Charts",
+ "label": "Warm build: note",
+ "text": "Libraries already compiled; the app rebuilt from clean. Median of three.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.build_edit.title",
+ "section": "Charts",
+ "label": "One-line change: title",
+ "text": "One-line change",
+ "context": "Title of one chart. The edit-and-rebuild wait a developer feels.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.build_edit.note",
+ "section": "Charts",
+ "label": "One-line change: note",
+ "text": "Rebuild after changing one line in the code that serves the page. Median of three.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.loc.title",
+ "section": "Charts",
+ "label": "Lines of code: title",
+ "text": "Lines of code",
+ "context": "Title of one chart. How much code each app needed.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.loc.note",
+ "section": "Charts",
+ "label": "Lines of code: note",
+ "text": "Non-blank lines of each app's own code. Tests are not counted.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.cost.title",
+ "section": "Charts",
+ "label": "Cost to build: title",
+ "text": "Cost to build",
+ "context": "Title of one chart. AI spend per app.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.cost.note",
+ "section": "Charts",
+ "label": "Cost to build: note",
+ "text": "What Opus 5.5 cost at list price for the build and its fix rounds.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.tokens.title",
+ "section": "Charts",
+ "label": "Tokens written to build: title",
+ "text": "Tokens written to build",
+ "context": "Title of one chart. Another view of the same effort.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.tokens.note",
+ "section": "Charts",
+ "label": "Tokens written to build: note",
+ "text": "Output tokens Opus 5.5 produced for the build and its fix rounds.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.readability.title",
+ "section": "Charts",
+ "label": "Readability: title",
+ "text": "Readability",
+ "context": "Title of one chart. How easy the code is to read.",
+ "notes": "Short."
+ },
+ {
+ "id": "chart.readability.note",
+ "section": "Charts",
+ "label": "Readability: note",
+ "text": "A blind score out of 10 from Fable, a different model from the builder. Median of three readings.",
+ "context": "The line under the chart title that says exactly what was measured.",
+ "notes": "One sentence or two."
+ },
+ {
+ "id": "chart.loc.server",
+ "section": "Charts",
+ "label": "Lines of code legend: server",
+ "text": "server",
+ "context": "A legend entry on the lines-of-code chart, naming one part of the bar.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "chart.loc.templates",
+ "section": "Charts",
+ "label": "Lines of code legend: templates",
+ "text": "templates",
+ "context": "A legend entry on the lines-of-code chart, naming one part of the bar.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "chart.loc.js",
+ "section": "Charts",
+ "label": "Lines of code legend: JavaScript",
+ "text": "JavaScript",
+ "context": "A legend entry on the lines-of-code chart, naming one part of the bar.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "unit.connections",
+ "section": "Labels",
+ "label": "Connections value",
+ "text": "{n} connections",
+ "context": "Shown at the end of a bar in the slow-down charts, e.g. \"32 connections\".",
+ "notes": "Keep {n}."
+ },
+ {
+ "id": "unit.out_of_ten",
+ "section": "Labels",
+ "label": "Readability value",
+ "text": "{n} / 10",
+ "context": "Shown at the end of a bar in the readability chart.",
+ "notes": "Keep {n}."
+ },
+ {
+ "id": "label.errors_from",
+ "section": "Labels",
+ "label": "Errors marker",
+ "text": " · Carloku turned some requests away from {n}",
+ "context": "Appended after a slow-down value when, at the next step, Carloku's edge started answering some requests with 503 (its limit of 64 requests in flight per home box). E.g. \"64 connections · Carloku turned some requests away from 128\".",
+ "notes": "Starts with a space and a dot. Keep {n}."
+ },
+ {
+ "id": "label.still_scaling",
+ "section": "Labels",
+ "label": "Still scaling",
+ "text": "still scaling at {n}",
+ "context": "Shown in place of a bar when an app never slowed down within the test.",
+ "notes": "Keep {n}."
+ },
+ {
+ "id": "label.blocked",
+ "section": "Labels",
+ "label": "Blocked",
+ "text": "blocked",
+ "context": "Shown in place of a bar for an app that couldn't run on Carloku. None remain, but the label exists."
+ },
+ {
+ "id": "label.not_measured",
+ "section": "Labels",
+ "label": "Not measured",
+ "text": "not measured",
+ "context": "Shown in place of a bar when a number is missing."
+ },
+ {
+ "id": "apps.heading",
+ "section": "The apps",
+ "label": "Heading",
+ "text": "The apps",
+ "context": "Heading over one short card per language with its links and notes."
+ },
+ {
+ "id": "apps.live",
+ "section": "The apps",
+ "label": "Label: Live app",
+ "text": "Live app",
+ "context": "A label in each app's card. Link to the running app.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "apps.source",
+ "section": "The apps",
+ "label": "Label: Source",
+ "text": "Source",
+ "context": "A label in each app's card. Link to the app's code.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "apps.stack",
+ "section": "The apps",
+ "label": "Label: Stack",
+ "text": "Stack",
+ "context": "A label in each app's card. What the app is built with.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "apps.readability",
+ "section": "The apps",
+ "label": "Label: Readability",
+ "text": "Readability",
+ "context": "A label in each app's card. Fable's short note on the code.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "apps.unfixed",
+ "section": "The apps",
+ "label": "Label: Left after review",
+ "text": "Left after review",
+ "context": "A label in each app's card. Review findings that weren't fixed in the one fix round, quoted from the reviewer.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "apps.note",
+ "section": "The apps",
+ "label": "Label: Note",
+ "text": "Note",
+ "context": "A label in each app's card. Anything else worth knowing about this app.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "apps.status",
+ "section": "The apps",
+ "label": "Label: Status",
+ "text": "Status",
+ "context": "A label in each app's card. Whether the app is running.",
+ "notes": "One or two words."
+ },
+ {
+ "id": "method.heading",
+ "section": "How we measured",
+ "label": "Heading",
+ "text": "How we measured",
+ "context": "Heading over the method section."
+ },
+ {
+ "id": "method.body",
+ "section": "How we measured",
+ "label": "Body",
+ "text": "PROSE_PENDING",
+ "context": "Explains the process: same brief, same model, same tests, review and fix rounds, readability scoring, and how the numbers were taken.",
+ "notes": "Markdown."
+ },
+ {
+ "id": "caveats.heading",
+ "section": "Caveats",
+ "label": "Heading",
+ "text": "What this does and doesn't show",
+ "context": "Heading over the limits of the comparison."
+ },
+ {
+ "id": "caveats.body",
+ "section": "Caveats",
+ "label": "Body",
+ "text": "PROSE_PENDING",
+ "context": "Plain list of what to be careful about when reading the numbers.",
+ "notes": "Markdown list."
+ }
+]
\ No newline at end of file
diff --git a/harness/apps.go b/harness/apps.go
index c64b471..23ca7ed 100644
--- a/harness/apps.go
+++ b/harness/apps.go
@@ -41,7 +41,7 @@ func Host(slug string) string {
// hostOverride: Spinel's -2 run broke mid-way (its process exits under
// load), and a used database can't be reset, so it was measured on a third
-// fresh instance (2026-10-24).
-var hostOverride = map[string]string{"spinel": "todo-spinel-3.bfj.oncarlos.com"}
+// fresh instance; its -3 results were lost to an operator slip, so -4 (2026-10-24).
+var hostOverride = map[string]string{"spinel": "todo-spinel-4.bfj.oncarlos.com"}
func URL(slug string) string { return "https://" + Host(slug) }
diff --git a/harness/cmd/bakeoff/main.go b/harness/cmd/bakeoff/main.go
index 4a11a06..6661615 100644
--- a/harness/cmd/bakeoff/main.go
+++ b/harness/cmd/bakeoff/main.go
@@ -361,12 +361,16 @@ func readability(slug, replies string) error {
// in the contestants' fixed order, with prose from the copy file.
func renderReport(copyFile string) error {
if copyFile == "" {
- copyFile = "harness/report/copy.md"
+ copyFile = "copy-review/strings.json"
}
text, err := os.ReadFile(copyFile)
if err != nil {
return err
}
+ words, err := report.LoadStrings(text)
+ if err != nil {
+ return err
+ }
var rs []b.AppResult
for _, a := range b.Apps {
r, err := b.LoadIn(b.ResultsDir, a.Slug)
@@ -381,7 +385,7 @@ func renderReport(copyFile string) error {
}
rs = append(rs, r)
}
- return report.Render(rs, report.ParseCopy(string(text)), os.Stdout)
+ return report.Render(rs, words, os.Stdout)
}
// findings records what a review left unfixed and exits 4 if any of it
diff --git a/harness/report/markdown.go b/harness/report/markdown.go
index 88d1693..7da3087 100644
--- a/harness/report/markdown.go
+++ b/harness/report/markdown.go
@@ -1,16 +1,46 @@
package report
import (
+ "encoding/json"
"html"
"html/template"
"regexp"
"strings"
)
-// Copy is the report's prose, keyed by section. ParseCopy reads it from a
-// Markdown file whose "## key" headings name the sections.
+// Copy is every word the report shows, keyed by the ids the copy review
+// uses (copy-review/strings.json). ParseCopy also reads a Markdown file
+// whose "## key" headings name the sections; LoadStrings reads the review's
+// JSON array.
type Copy map[string]string
+// T is the text for key, with {n}-style placeholders filled from args
+// (name, value, name, value…). A missing key renders as ⟦key⟧, which the
+// tests reject, so a page never ships with a hole in it.
+func (c Copy) T(key string, args ...string) string {
+ s, ok := c[key]
+ if !ok {
+ return "⟦" + key + "⟧"
+ }
+ for i := 0; i+1 < len(args); i += 2 {
+ s = strings.ReplaceAll(s, "{"+args[i]+"}", args[i+1])
+ }
+ return s
+}
+
+// LoadStrings reads copy-review's strings.json (an array of {id, text}).
+func LoadStrings(b []byte) (Copy, error) {
+ var arr []struct{ ID, Text string }
+ if err := json.Unmarshal(b, &arr); err != nil {
+ return nil, err
+ }
+ c := Copy{}
+ for _, s := range arr {
+ c[s.ID] = s.Text
+ }
+ return c, nil
+}
+
// ParseCopy splits text on lines of the form "## key".
func ParseCopy(text string) Copy {
c := Copy{}
diff --git a/harness/report/report.go b/harness/report/report.go
index f876402..758ab45 100644
--- a/harness/report/report.go
+++ b/harness/report/report.go
@@ -16,7 +16,8 @@ import (
var pageSrc string
var page = template.Must(template.New("page").Funcs(template.FuncMap{
- "md": func(c Copy, key string) template.HTML { return markdown(c[key]) },
+ "md": func(c Copy, key string) template.HTML { return markdown(c.T(key)) },
+ "t": func(c Copy, key string) string { return c.T(key) },
"ms": ms,
"mb": mb,
"num": num,
@@ -70,10 +71,10 @@ func knee(r b.AppResult, scenario string) b.KneeResult {
return b.Knee(sweep)
}
-// kneeChart shows, per app, the concurrency it still scaled at; an app
-// that never stopped scaling shows that instead of a bar.
-func kneeChart(rs []b.AppResult, scenario, title, note string) Chart {
- c := chart(rs, title, note, func(v float64) string { return num(v) + " connections" },
+// kneeChart shows, per app, where it stopped scaling; an app that never
+// did shows that instead of a bar, and errors are named after the value.
+func kneeChart(rs []b.AppResult, c Copy, scenario, id string) Chart {
+ ch := chart(rs, c, id, func(v float64) string { return c.T("unit.connections", "n", num(v)) },
func(r b.AppResult) (float64, bool) {
k := knee(r, scenario)
return float64(k.Concurrency), k.Found
@@ -83,83 +84,68 @@ func kneeChart(rs []b.AppResult, scenario, title, note string) Chart {
switch {
case !live(r):
case k.ErrorsAt > 0:
- c.Rows[i].Suffix = " · errors from " + num(float64(k.ErrorsAt))
+ ch.Rows[i].Suffix = c.T("label.errors_from", "n", num(float64(k.ErrorsAt)))
case !k.Found && k.MaxTested > 0:
- c.Rows[i].Missing = "still scaling at " + num(float64(k.MaxTested))
+ ch.Rows[i].Missing = c.T("label.still_scaling", "n", num(float64(k.MaxTested)))
}
}
- return c
+ return ch
}
-// chart builds one chart over all results; get returns ok=false when the
-// app has no measurement, which is shown as "not measured" (or the reason
-// the app is blocked).
-func chart(rs []b.AppResult, title, note string, format func(float64) string, get func(b.AppResult) (float64, bool)) Chart {
- c := Chart{Title: title, Note: note, Format: format}
+// chart builds the chart whose title and note are chart.<id>.title and
+// chart.<id>.note; get returns ok=false when the app has no measurement.
+func chart(rs []b.AppResult, c Copy, id string, format func(float64) string, get func(b.AppResult) (float64, bool)) Chart {
+ ch := Chart{Title: c.T("chart." + id + ".title"), Note: c.T("chart." + id + ".note"), Format: format}
for _, r := range rs {
row := Row{Language: r.Language}
if !live(r) {
- row.Missing = "blocked"
+ row.Missing = c.T("label.blocked")
} else if v, ok := get(r); ok {
row.Value = v
} else {
- row.Missing = "not measured"
+ row.Missing = c.T("label.not_measured")
}
- c.Rows = append(c.Rows, row)
+ ch.Rows = append(ch.Rows, row)
}
- return c
+ return ch
}
-func charts(rs []b.AppResult) []Chart {
+func charts(rs []b.AppResult, c Copy) []Chart {
pos := func(v float64) (float64, bool) { return v, v > 0 }
- loc := Chart{Title: "Lines of code", Note: "Non-blank lines of the app's own code, excluding tests.",
- Format: func(v float64) string { return num(v) }, Legend: []string{"server", "templates", "JavaScript"}}
+ loc := Chart{Title: c.T("chart.loc.title"), Note: c.T("chart.loc.note"),
+ Format: func(v float64) string { return num(v) },
+ Legend: []string{c.T("chart.loc.server"), c.T("chart.loc.templates"), c.T("chart.loc.js")}}
for _, r := range rs {
row := Row{Language: r.Language}
- c := r.Code
+ code := r.Code
if !live(r) {
- row.Missing = "blocked"
- } else if c.Server+c.Templates+c.JS == 0 {
- row.Missing = "not measured"
+ row.Missing = c.T("label.blocked")
+ } else if code.Server+code.Templates+code.JS == 0 {
+ row.Missing = c.T("label.not_measured")
} else {
- row.Parts = []float64{float64(c.Server), float64(c.Templates), float64(c.JS)}
- row.Value = float64(c.Server + c.Templates + c.JS)
+ row.Parts = []float64{float64(code.Server), float64(code.Templates), float64(code.JS)}
+ row.Value = float64(code.Server + code.Templates + code.JS)
}
loc.Rows = append(loc.Rows, row)
}
+ all := func(r b.AppResult) b.Tokens { return r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity) }
return []Chart{
- chart(rs, "Time to first byte, empty list", "Median of 30 requests from docs-t1, each on a new connection.", ms,
- func(r b.AppResult) (float64, bool) { return pos(r.TTFBEmpty.P50) }),
- chart(rs, "Time to first byte, 50 items", "Median of 30 requests from docs-t1, each on a new connection.", ms,
- func(r b.AppResult) (float64, bool) { return pos(r.TTFB50.P50) }),
- chart(rs, "Wake from sleep", "Time to first byte on the first request after the instance went to sleep. Median of 3.", ms,
- func(r b.AppResult) (float64, bool) { return pos(medianOf(r.Wake)) }),
- chart(rs, "Peak reads per second", "GET / on a 50-item list, ramping from 1 to 1,024 concurrent connections, 10 s per step.", num,
- func(r b.AppResult) (float64, bool) { return pos(knee(r, "read").PeakRPS) }),
- kneeChart(rs, "read", "Reads: where it slows down", "The fewest concurrent connections from which more load never raised throughput by 10% or more. Errors: the first step where more than 1% of requests failed."),
- chart(rs, "Peak toggles per second", "POST toggling one item, ramping the same way.", num,
- func(r b.AppResult) (float64, bool) { return pos(knee(r, "toggle").PeakRPS) }),
- chart(rs, "Peak reads per second, after the write ramp", "The same read ramp, run again straight after the toggle ramp on the same database.", num,
- func(r b.AppResult) (float64, bool) { return pos(knee(r, "read-after").PeakRPS) }),
- kneeChart(rs, "toggle", "Toggles: where it slows down", "The fewest concurrent connections from which more load never raised throughput by 10% or more. Errors: the first step where more than 1% of requests failed."),
- chart(rs, "Binary size", "The linux-arm64 binary that ships.", mb,
- func(r b.AppResult) (float64, bool) { return pos(float64(r.Build.Bytes)) }),
- chart(rs, "Cold build", "Every library compiled from already-downloaded sources.", secs,
- func(r b.AppResult) (float64, bool) { return pos(r.Build.ColdSeconds) }),
- chart(rs, "Warm build", "Libraries already compiled; the app rebuilt from clean.", secs,
- func(r b.AppResult) (float64, bool) { return pos(r.Build.WarmSeconds) }),
- chart(rs, "One-line change", "Rebuild after changing one string in the request-handling code.", secs,
- func(r b.AppResult) (float64, bool) { return pos(r.Build.EditSeconds) }),
+ chart(rs, c, "ttfb_empty", ms, func(r b.AppResult) (float64, bool) { return pos(r.TTFBEmpty.P50) }),
+ chart(rs, c, "ttfb_50", ms, func(r b.AppResult) (float64, bool) { return pos(r.TTFB50.P50) }),
+ chart(rs, c, "wake", ms, func(r b.AppResult) (float64, bool) { return pos(medianOf(r.Wake)) }),
+ chart(rs, c, "reads_peak", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "read").PeakRPS) }),
+ kneeChart(rs, c, "read", "reads_knee"),
+ chart(rs, c, "toggles_peak", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "toggle").PeakRPS) }),
+ kneeChart(rs, c, "toggle", "toggles_knee"),
+ chart(rs, c, "reads_after", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "read-after").PeakRPS) }),
+ chart(rs, c, "binary", mb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Build.Bytes)) }),
+ chart(rs, c, "build_cold", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.ColdSeconds) }),
+ chart(rs, c, "build_warm", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.WarmSeconds) }),
+ chart(rs, c, "build_edit", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.EditSeconds) }),
loc,
- chart(rs, "Cost to build", "Opus 5.5 build plus fix rounds, at list price.", usd,
- func(r b.AppResult) (float64, bool) {
- return pos(r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity).CostUSD)
- }),
- chart(rs, "Output tokens to build", "Tokens Opus 5.5 wrote, build plus fix rounds.", num,
- func(r b.AppResult) (float64, bool) {
- return pos(float64(r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity).Output))
- }),
- chart(rs, "Readability", "Fable's blind score out of 10; median of three readings.", func(v float64) string { return fmt.Sprintf("%.0f / 10", v) },
+ chart(rs, c, "cost", usd, func(r b.AppResult) (float64, bool) { return pos(all(r).CostUSD) }),
+ chart(rs, c, "tokens", num, func(r b.AppResult) (float64, bool) { return pos(float64(all(r).Output)) }),
+ chart(rs, c, "readability", func(v float64) string { return c.T("unit.out_of_ten", "n", fmt.Sprintf("%.0f", v)) },
func(r b.AppResult) (float64, bool) { return pos(r.Readability.Overall) }),
}
}
@@ -170,5 +156,5 @@ func Render(rs []b.AppResult, copy Copy, w io.Writer) error {
Results []b.AppResult
Charts []Chart
Copy Copy
- }{rs, charts(rs), copy})
+ }{rs, charts(rs, copy), copy})
}
diff --git a/harness/report/report.html.tmpl b/harness/report/report.html.tmpl
index 69dfb16..ea2648d 100644
--- a/harness/report/report.html.tmpl
+++ b/harness/report/report.html.tmpl
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{{index .Copy "title"}}</title>
+<title>{{t .Copy "page.title"}}</title>
<style>
:root {
color-scheme: light;
@@ -50,13 +50,16 @@ dt { color: var(--muted); } dd { margin: 0; color: var(--ink-2); }
</head>
<body>
<main>
-<h1>{{index .Copy "title"}}</h1>
-{{md .Copy "intro"}}
+<h1>{{t .Copy "page.title"}}</h1>
+{{md .Copy "page.intro"}}
-<h2>At a glance</h2>
+<h2>{{t .Copy "findings.heading"}}</h2>
+{{md .Copy "findings.body"}}
+
+<h2>{{t .Copy "glance.heading"}}</h2>
<div class="card scroll">
<table>
-<thead><tr><th>Setup</th><th>Tests (JS on / off)</th><th>First byte</th><th>Wake</th><th>Peak reads/s</th><th>Binary</th><th>Cold / warm build</th><th>Lines</th><th>Deps</th><th>Cost</th><th>Readability</th></tr></thead>
+<thead><tr><th>{{t .Copy "glance.col.setup"}}</th><th>{{t .Copy "glance.col.tests"}}</th><th>{{t .Copy "glance.col.first_byte"}}</th><th>{{t .Copy "glance.col.wake"}}</th><th>{{t .Copy "glance.col.reads"}}</th><th>{{t .Copy "glance.col.binary"}}</th><th>{{t .Copy "glance.col.build"}}</th><th>{{t .Copy "glance.col.lines"}}</th><th>{{t .Copy "glance.col.deps"}}</th><th>{{t .Copy "glance.col.cost"}}</th><th>{{t .Copy "glance.col.readability"}}</th></tr></thead>
<tbody>
{{- range .Results}}
{{- if live .}}
@@ -80,33 +83,33 @@ dt { color: var(--muted); } dd { margin: 0; color: var(--ink-2); }
{{.SVG}}
{{- if .HasParts}}
<ul class="legend">{{range $i, $p := .Parts}}<li style="--k: var(--s{{inc $i}})">{{$p}}</li>{{end}}</ul>
-<div class="scroll"><table><thead><tr><th>Setup</th>{{range .Parts}}<th>{{.}}</th>{{end}}</tr></thead><tbody>
+<div class="scroll"><table><thead><tr><th>{{t $.Copy "glance.col.setup"}}</th>{{range .Parts}}<th>{{.}}</th>{{end}}</tr></thead><tbody>
{{- range .Rows}}{{if .Parts}}<tr><td>{{.Language}}</td>{{range .Parts}}<td>{{num .}}</td>{{end}}</tr>{{end}}{{end}}
</tbody></table></div>
{{- end}}
</div>
{{end}}
-<h2>The apps</h2>
+<h2>{{t .Copy "apps.heading"}}</h2>
{{range .Results}}
<div class="card">
<h3>{{.Language}}</h3>
<dl>
-<dt>Status</dt><dd>{{.Status}}</dd>
-{{- if .URL}}<dt>Live app</dt><dd><a href="{{.URL}}">{{.URL}}</a></dd>{{end}}
-{{- if .Source}}<dt>Source</dt><dd><a href="{{.Source}}">{{.Source}}</a></dd>{{end}}
-{{- if .Stack}}<dt>Stack</dt><dd>{{.Stack}}</dd>{{end}}
-{{- if .Readability.Notes}}<dt>Readability</dt><dd>{{.Readability.Notes}}</dd>{{end}}
-{{- if .ReviewFindings}}<dt>Not fixed after review</dt><dd><ul>{{range .ReviewFindings}}<li>{{.}}</li>{{end}}</ul></dd>{{end}}
-{{- range .Notes}}<dt>Note</dt><dd>{{.}}</dd>{{end}}
+<dt>{{t $.Copy "apps.status"}}</dt><dd>{{.Status}}</dd>
+{{- if .URL}}<dt>{{t $.Copy "apps.live"}}</dt><dd><a href="{{.URL}}">{{.URL}}</a></dd>{{end}}
+{{- if .Source}}<dt>{{t $.Copy "apps.source"}}</dt><dd><a href="{{.Source}}">{{.Source}}</a></dd>{{end}}
+{{- if .Stack}}<dt>{{t $.Copy "apps.stack"}}</dt><dd>{{.Stack}}</dd>{{end}}
+{{- if .Readability.Notes}}<dt>{{t $.Copy "apps.readability"}}</dt><dd>{{.Readability.Notes}}</dd>{{end}}
+{{- if .ReviewFindings}}<dt>{{t $.Copy "apps.unfixed"}}</dt><dd><ul>{{range .ReviewFindings}}<li>{{.}}</li>{{end}}</ul></dd>{{end}}
+{{- range .Notes}}<dt>{{t $.Copy "apps.note"}}</dt><dd>{{.}}</dd>{{end}}
</dl>
</div>
{{end}}
-<h2>{{index .Copy "method-heading"}}</h2>
-{{md .Copy "method"}}
-<h2>{{index .Copy "caveats-heading"}}</h2>
-{{md .Copy "caveats"}}
+<h2>{{t .Copy "method.heading"}}</h2>
+{{md .Copy "method.body"}}
+<h2>{{t .Copy "caveats.heading"}}</h2>
+{{md .Copy "caveats.body"}}
</main>
</body>
</html>
diff --git a/harness/report/report_test.go b/harness/report/report_test.go
index 68ebed4..851ef56 100644
--- a/harness/report/report_test.go
+++ b/harness/report/report_test.go
@@ -2,6 +2,7 @@ package report
import (
"bytes"
+ "os"
"strings"
"testing"
@@ -30,22 +31,41 @@ func fixture() []b.AppResult {
func TestRenderAllApps(t *testing.T) {
var buf bytes.Buffer
- copy := Copy{"intro": "Nine apps.\n\nOne brief.", "method": "- one\n- two [link](https://example.com)"}
+ raw, err := os.ReadFile("../../copy-review/strings.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ copy, err := LoadStrings(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ copy["page.intro"] = "Nine apps.\n\nOne brief."
+ copy["method.body"] = "- one\n- two [link](https://example.com)"
if err := Render(fixture(), copy, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{
"Go", "Mojo", "single-file binaries only", "https://todo-go.bfj.oncarlos.com", "<svg",
- "<p>Nine apps.</p>", `<a href="https://example.com">link</a>`, "Clear &amp; tidy &lt;ok&gt;", "9.4 MB", "2 connections", "2 connections · errors from 4",
+ "<p>Nine apps.</p>", `<a href="https://example.com">link</a>`, "Clear &amp; tidy &lt;ok&gt;", "9.4 MB", "2 connections", "2 connections · Carloku turned some requests away from 4",
} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q", want)
}
}
- for _, bad := range []string{"NaN", "<no value>", "Inf", "<script"} {
+ for _, bad := range []string{"NaN", "<no value>", "Inf", "<script", "⟦"} {
if strings.Contains(out, bad) {
t.Errorf("output contains %q", bad)
}
}
}
+
+func TestMissingCopyIsVisible(t *testing.T) {
+ var buf bytes.Buffer
+ if err := Render(fixture(), Copy{}, &buf); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(buf.String(), "⟦page.title⟧") {
+ t.Fatal("a missing string must render as ⟦id⟧")
+ }
+}