From 3f49de6c347f94d3653e20020c5a7800785ae027 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 22 Oct 2026 01:33:07 -0300 Subject: [PATCH] Every screen of the app, in both themes and at both widths spin's catalog spec draws every route, tab, dialog and menu at 1440 and 390 px, light and dark, and lists what each draws that the palette does not have. cmd/screens keeps the catalog and that audit in screens/ and puts the images, hundreds a release, on a release of this repository rather than in git; Pages unpacks them, and screens.html shows them side by side with their findings. The lockup's word is spinbox, the product's name. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pages.yml | 9 ++ README.md | 1 + Taskfile.yml | 8 + cmd/screens/main.go | 295 ++++++++++++++++++++++++++++++++++++ cmd/screens/main_test.go | 51 +++++++ index.html | 8 +- screens.html | 160 +++++++++++++++++++ 7 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 cmd/screens/main.go create mode 100644 cmd/screens/main_test.go create mode 100644 screens.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 35bc1a1..d688feb 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -31,6 +31,15 @@ jobs: - run: go run ./cmd/palette -check - run: go run ./cmd/mark -check - run: go run ./cmd/shots -check + - run: go run ./cmd/screens -check + # The screens' images are not in git: screens.json names the release that holds them, and + # the page serves them from screens/img. + - if: github.ref == 'refs/heads/main' && hashFiles('screens/screens.json') != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p screens/img + gh release download "$(jq -r .tag screens/screens.json)" --repo "${{ github.repository }}" --pattern screens.tar.gz --output - | tar -xz -C screens/img - if: github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: diff --git a/README.md b/README.md index 7639034..bd3a0e8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ through protanopia, deuteranopia or tritanopia, with every pair the palette is h |---|---| | `palette.toml` | `dist/`: `tokens.css` (every role as `--spin-*`), `starlight.css` (the website's `--sl-color-*`), `dashboard.css` (shadcn's HSL variables, light on `:root`, dark on `.dark`), `tokens.json`, and `perception.js` and `simulate.svg` for the preview | | spin's `dashboard/e2e/site.spec.ts` | `shots/`: the app as it is, screenshots spin's dashboard suite takes of its e2e stack, with the release and commit of the spin tree that took them (`task screenshots SHOTS=`); the website copies them from here | +| spin's `dashboard/e2e/catalog.spec.ts` | `screens/`: every route, tab, dialog and menu of the app in both themes at 1440 and 390 px, and what each draws that this palette does not have - a colour, a face, a size, a radius, contrast, overflow, a target too small (`task screens SCREENS=`). The catalog and the audit are committed; the images, hundreds a release, are an archive on a release of this repository that `screens.json` names and Pages unpacks. [See them](https://spin-stack.github.io/brand/screens.html) | | `assets/avatar.png` | `logos/`: the stack of disks cut from the organization's avatar, without the wordmark: the tile on its own navy (512, 256; the favicon at 64 and 32) for a dark ground, and clear - the ground taken out - (512, 256) for a light one | ``` diff --git a/Taskfile.yml b/Taskfile.yml index 8aa89bf..696d4d4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -32,6 +32,13 @@ tasks: cmds: - go run ./cmd/shots -spin {{.SPIN}} -from {{.SHOTS}} + screens: + desc: Keep every screen of the app from SCREENS=, where `CATALOG_SHOTS= task e2e:dashboard -- catalog.spec.ts` in a spin checkout (SPIN=, by default ../spin) wrote them - the catalog and the audit in screens/, the images on a release of this repository. + vars: + SPIN: '{{.SPIN | default "../spin"}}' + cmds: + - go run ./cmd/screens -spin {{.SPIN}} -from {{.SCREENS}} + preview: desc: Serve index.html, the laboratory - the site, the dashboard and a phone's width in the palette, in either mode and through each deficiency - at http://127.0.0.1:4330/ cmds: @@ -45,3 +52,4 @@ tasks: - go run ./cmd/palette -check - go run ./cmd/mark -check - go run ./cmd/shots -check + - go run ./cmd/screens -check diff --git a/cmd/screens/main.go b/cmd/screens/main.go new file mode 100644 index 0000000..5619a3f --- /dev/null +++ b/cmd/screens/main.go @@ -0,0 +1,295 @@ +// Command screens keeps every screen and dialog of the app, in each theme and at each width, beside +// the palette that draws them, with what spin's suite found off the palette on each. spin's +// dashboard suite takes them (dashboard/e2e/catalog.spec.ts walks dashboard/e2e/catalog.ts, which a +// test holds to every route and dialog the app has) and writes, into one directory: +// +// - catalog.json: each screen - its name, title, area, route and whether it is a page or a dialog - +// and each route or dialog it leaves out, with the reason; +// - .audit.json: each value a screen draws that brand does not have, and each rule it +// breaks; +// - ...png for every screen, theme and width. +// +// The two JSON files are committed in screens/, so a change in what the app is shows as a diff. The +// images are not: there are hundreds a release, and git would keep every one forever. They go up as +// one archive on a release of this repository, which screens.json names and Pages unpacks. +// With -check it fails on a catalog that is not whole: a screen without each of its images in the +// archive's list, or an audit finding about a screen the catalog does not have. +package main + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +var ( + themes = []string{"light", "dark"} + widths = []string{"desktop", "mobile"} +) + +// Archive is the asset's name on each release. +const archive = "screens.tar.gz" + +type screen struct { + Name string `json:"name"` + Title string `json:"title"` + Area string `json:"area"` + Route string `json:"route"` + Kind string `json:"kind"` +} + +type exemption struct { + What string `json:"what"` + Reason string `json:"reason"` +} + +type catalog struct { + Screens []screen `json:"screens"` + Exempt []exemption `json:"exempt"` +} + +type finding struct { + Screen string `json:"screen"` + Theme string `json:"theme"` + Width string `json:"width"` + Kind string `json:"kind"` + Value string `json:"value"` + Selector string `json:"selector"` + Count int `json:"count"` +} + +type audit struct { + Findings []finding `json:"findings"` +} + +// index is screens/screens.json: the catalog with what took it and where its images are. +type index struct { + Release string `json:"release"` + Commit string `json:"commit"` + Tag string `json:"tag"` + Images []string `json:"images"` + Screens []screen `json:"screens"` + Exempt []exemption `json:"exempt"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "screens:", err) + os.Exit(1) + } +} + +func run() error { + spin := flag.String("spin", "../spin", "the spin checkout whose dashboard suite took the screens") + from := flag.String("from", "", "where the suite wrote them: CATALOG_SHOTS= task e2e:dashboard -- catalog.spec.ts") + out := flag.String("out", "screens", "where the catalog and the audit are kept") + repo := flag.String("repo", "spin-stack/brand", "the repository whose release holds the images") + check := flag.Bool("check", false, "write nothing; fail on a catalog that is not whole") + flag.Parse() + + if *check { + ix, a, err := readKept(*out) + if errors.Is(err, os.ErrNotExist) { + fmt.Println("screens: none taken yet") + return nil + } + if err != nil { + return err + } + if err := whole(ix.Screens, ix.Images, a); err != nil { + return err + } + fmt.Printf("screens: %d screens of spin %s, %d findings, images on %s\n", len(ix.Screens), ix.Release, len(a.Findings), ix.Tag) + return nil + } + if *from == "" { + return errors.New("-from names where spin's suite wrote the screens") + } + + var c catalog + if err := readJSON(filepath.Join(*from, "catalog.json"), &c); err != nil { + return err + } + // One file a screen: a test that is retried starts a new worker, which would lose what an + // earlier test had gathered in memory. + var a audit + audits, err := filepath.Glob(filepath.Join(*from, "*.audit.json")) + if err != nil { + return err + } + for _, f := range audits { + var found []finding + if err := readJSON(f, &found); err != nil { + return err + } + a.Findings = append(a.Findings, found...) + } + taken, err := filepath.Glob(filepath.Join(*from, "*.png")) + if err != nil { + return err + } + images := make([]string, 0, len(taken)) + for _, t := range taken { + images = append(images, filepath.Base(t)) + } + slices.Sort(images) + if err := whole(c.Screens, images, a); err != nil { + return err + } + + ix := index{Images: images, Screens: c.Screens, Exempt: c.Exempt} + if ix.Release, err = git(*spin, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"); err != nil { + return err + } + if ix.Commit, err = git(*spin, "rev-parse", "HEAD"); err != nil { + return err + } + ix.Tag = fmt.Sprintf("screens-%s-%s", ix.Release, ix.Commit[:12]) + + tmp, err := os.MkdirTemp("", "screens") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + tgz := filepath.Join(tmp, archive) + if err := pack(tgz, *from, images); err != nil { + return err + } + notes := fmt.Sprintf("Every screen of spin %s (%s), in each theme and at each width: %d images. screens/screens.json names this release.", ix.Release, ix.Commit, len(images)) + gh := exec.Command("gh", "release", "create", ix.Tag, tgz, "--repo", *repo, "--title", ix.Tag, "--notes", notes, "--latest=false") + gh.Stdout, gh.Stderr = os.Stdout, os.Stderr + if err := gh.Run(); err != nil { + return fmt.Errorf("gh release create %s: %w", ix.Tag, err) + } + + if err := os.MkdirAll(*out, 0o755); err != nil { + return err + } + if err := writeJSON(filepath.Join(*out, "screens.json"), ix); err != nil { + return err + } + return writeJSON(filepath.Join(*out, "audit.json"), a) +} + +// whole is the catalog's promise: each screen in each theme at each width, nothing else, and +// every finding about one of them. +func whole(screens []screen, images []string, a audit) error { + var errs []error + want := map[string]bool{} + names := map[string]bool{} + for _, s := range screens { + if names[s.Name] { + errs = append(errs, fmt.Errorf("two screens are named %s", s.Name)) + } + names[s.Name] = true + if s.Kind != "page" && s.Kind != "dialog" { + errs = append(errs, fmt.Errorf("%s is a %q, not a page or a dialog", s.Name, s.Kind)) + } + for _, t := range themes { + for _, w := range widths { + want[fmt.Sprintf("%s.%s.%s.png", s.Name, t, w)] = true + } + } + } + have := map[string]bool{} + for _, i := range images { + have[i] = true + if !want[i] { + errs = append(errs, fmt.Errorf("%s is no screen of the catalog in a theme and a width", i)) + } + } + for w := range want { + if !have[w] { + errs = append(errs, fmt.Errorf("%s was not taken", w)) + } + } + for _, f := range a.Findings { + if !names[f.Screen] || !slices.Contains(themes, f.Theme) || !slices.Contains(widths, f.Width) { + errs = append(errs, fmt.Errorf("a finding about %s.%s.%s, which the catalog does not have", f.Screen, f.Theme, f.Width)) + } + } + slices.SortFunc(errs, func(a, b error) int { return strings.Compare(a.Error(), b.Error()) }) + return errors.Join(errs...) +} + +func readKept(dir string) (index, audit, error) { + var ix index + var a audit + if err := readJSON(filepath.Join(dir, "screens.json"), &ix); err != nil { + return ix, a, err + } + return ix, a, readJSON(filepath.Join(dir, "audit.json"), &a) +} + +func pack(path, dir string, images []string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for _, name := range images { + src, err := os.Open(filepath.Join(dir, name)) + if err != nil { + return err + } + st, err := src.Stat() + if err != nil { + src.Close() + return err + } + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: st.Size()}); err != nil { + src.Close() + return err + } + _, err = io.Copy(tw, src) + src.Close() + if err != nil { + return err + } + } + if err := tw.Close(); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + return f.Close() +} + +func readJSON(path string, v any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + if err := json.Unmarshal(raw, v); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + return nil +} + +func writeJSON(path string, v any) error { + b, err := json.MarshalIndent(v, "", "\t") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o644) +} + +func git(dir string, args ...string) (string, error) { + out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() + if err != nil { + return "", fmt.Errorf("git %s in %s: %w", strings.Join(args, " "), dir, err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/cmd/screens/main_test.go b/cmd/screens/main_test.go new file mode 100644 index 0000000..045993a --- /dev/null +++ b/cmd/screens/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" +) + +func TestWholeRefusesACatalogWithAGap(t *testing.T) { + workspaces := screen{Name: "workspaces", Kind: "page"} + all := []string{ + "workspaces.dark.desktop.png", "workspaces.dark.mobile.png", + "workspaces.light.desktop.png", "workspaces.light.mobile.png", + } + cases := []struct { + name string + screens []screen + images []string + audit audit + refused string + }{ + {name: "every theme at every width", screens: []screen{workspaces}, images: all}, + {name: "a width not taken", screens: []screen{workspaces}, images: all[1:], refused: "workspaces.dark.desktop.png was not taken"}, + {name: "an image of no screen", screens: []screen{workspaces}, images: append(all, "gone.light.desktop.png"), refused: "gone.light.desktop.png is no screen"}, + {name: "a kind that is neither", screens: []screen{{Name: "workspaces", Kind: "drawer"}}, images: all, refused: `"drawer", not a page or a dialog`}, + {name: "one name twice", screens: []screen{workspaces, workspaces}, images: all, refused: "two screens are named workspaces"}, + { + name: "a finding about no screen", screens: []screen{workspaces}, images: all, + audit: audit{Findings: []finding{{Screen: "gone", Theme: "dark", Width: "desktop"}}}, + refused: "a finding about gone.dark.desktop", + }, + { + name: "a finding in a theme there is not", screens: []screen{workspaces}, images: all, + audit: audit{Findings: []finding{{Screen: "workspaces", Theme: "sepia", Width: "desktop"}}}, + refused: "a finding about workspaces.sepia.desktop", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := whole(c.screens, c.images, c.audit) + if c.refused == "" { + if err != nil { + t.Fatalf("refused a whole catalog: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Fatalf("got %v, want a refusal saying %q", err, c.refused) + } + }) + } +} diff --git a/index.html b/index.html index 8f35ff4..809e5ed 100644 --- a/index.html +++ b/index.html @@ -179,7 +179,7 @@
-
spin
+
spinbox

Spinbox brand system

One palette, one mark and one type for the site, the dashboard and everything else that draws Spinbox - each color checked here, and copied from here by each of them. This page is the laboratory; the product is at the website.

@@ -192,7 +192,7 @@

Spinbox brand system

@@ -276,8 +276,8 @@

Clear space

A quarter of the mark's height on every side, dashed above: no text or edge inside it.

Lockup

-
spin
-

The mark, then spin in IBM Plex Mono 600, its cap height the mark's middle disk.

+
spinbox
+

The mark, then spinbox in IBM Plex Mono 600, its cap height the mark's middle disk.

Not

stretched
diff --git a/screens.html b/screens.html new file mode 100644 index 0000000..c045aec --- /dev/null +++ b/screens.html @@ -0,0 +1,160 @@ + + + + + +Spinbox screens + + + + + +
+
+ spinbox +

Every screen

+

Every route, tab, dialog and menu of the dashboard, in both themes and at both widths, with what each draws that this palette does not have.

+
+
+
+
+ + + + + + + +
+
+
+
+
+
+ + +