carlos / todomvc-bakeoff Public
feat: every report string comes from copy-review/strings.json (draft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+675
−85
copy-review/strings.json+547 −0harness/apps.go+2 −2harness/cmd/bakeoff/main.go+6 −2harness/report/markdown.go+32 −2harness/report/report.go+44 −58harness/report/report.html.tmpl+21 −18harness/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 & tidy <ok>", "9.4 MB", "2 connections", "2 connections · errors from 4", |
| + "<p>Nine apps.</p>", `<a href="https://example.com">link</a>`, "Clear & tidy <ok>", "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⟧") |
| + } |
| +} |