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.

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