// Package report writes the two things a shoot produces: a page a person // reads, and a file Claude reads. // // They are separate on purpose. A person wants the pictures side by side, // worst first, in one file they can open from anywhere. A model wants a // list of paths and numbers and no HTML at all -- handing it the report // would spend most of a context window on base64. package report import ( "bytes" "encoding/json" "html/template" "os" "path/filepath" "sort" ) // Shot is one screen's outcome. type Shot struct { Name string `json:"name"` // Before, After and Diff are paths, relative to the output directory. // Before and Diff are empty in review mode, which has no baseline. Before string `json:"before,omitempty"` After string `json:"after"` Diff string `json:"diff,omitempty"` // Moved says whether this shot changed enough to be worth a look. Moved bool `json:"moved"` // Verdict is the one-line summary: a pixel count, or why not. Verdict string `json:"verdict"` // Error is set when the shot could not be taken at all. Error string `json:"error,omitempty"` // Past is how many CSS pixels the rendering extends beyond the // viewport it was taken for, and Ink says whether anything is drawn // out there. A page wider than its phone scrolls sideways, which is // always a bug; whether there is content past the edge is what says // how big a one. Both are zero and false on a page that fits. Past int `json:"overflow,omitempty"` Ink bool `json:"overflow_ink,omitempty"` // Width and Height are the candidate rendering's size. The report // needs them because one layout cannot serve both shapes a shoot // produces: a 1280-wide desktop page squeezed into a third of the // screen is unreadable, and a 390x1300 phone page is taller than the // screen it is being read on. Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` } // Wide reports whether a shot is a desktop-shaped rendering, which wants a // row to itself rather than a column beside its neighbours. func (s Shot) Wide() bool { return s.Width > s.Height/2 } // Run is a whole shoot. type Run struct { // Mode is "check" or "review". Mode string `json:"mode"` // Base names the baseline revision in check mode. Base string `json:"base,omitempty"` // Rubric is the design guide a reviewer should read, in review mode. Rubric string `json:"rubric,omitempty"` Shots []Shot `json:"shots"` // Moved and Total are the counts a caller acts on without walking the // list: if Moved is zero in check mode, nothing changed and there is // nothing to look at. Moved int `json:"moved"` Total int `json:"total"` } // Write puts result.json and report.html in dir. func Write(dir string, run *Run) error { sort.SliceStable(run.Shots, func(i, j int) bool { // Anything that moved, or failed, comes first: the point of the // page is the thing to look at, and it should not need scrolling // to reach. a, b := run.Shots[i], run.Shots[j] ai := a.Error != "" || a.Moved || a.Past > 0 bi := b.Error != "" || b.Moved || b.Past > 0 if ai != bi { return ai } return a.Name < b.Name }) run.Total = len(run.Shots) run.Moved = 0 for _, s := range run.Shots { if s.Moved || s.Error != "" || s.Past > 0 { run.Moved++ } } b, err := json.MarshalIndent(run, "", " ") if err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "result.json"), b, 0o644); err != nil { return err } var buf bytes.Buffer if err := page.Execute(&buf, run); err != nil { return err } return os.WriteFile(filepath.Join(dir, "report.html"), buf.Bytes(), 0o644) } // The report links its images rather than embedding them: a shoot of forty // full-page screenshots is tens of megabytes, and a self-contained file // that size is one no browser enjoys and no chat can carry. The directory // is the artifact; the page is its index. var page = template.Must(template.New("report").Parse(`
{{.Name}}
{{if .Error}}{{.Error}}{{else}}{{.Verdict}}{{end}}
{{if .Width}}{{.Width}}×{{.Height}}{{end}}
{{if .Past}}{{.Past}}px past the edge{{if not .Ink}}, nothing drawn there{{end}}{{end}}