diff --git a/README.md b/README.md index e62880f..14f8322 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,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 | +| `assets/mark.png` | `logos/`: the stack of disks, cut from the mark as it was drawn (1024 px on a clear ground): the tile on the brand's navy (512, 256; the touch icon at 180, the favicon at 64 and 32) for a dark ground, and clear (512, 256) for a light one. Each is the drawing reduced; a size it would have to enlarge fails | ``` task # palette and mark: check, and write dist/ and logos/ diff --git a/Taskfile.yml b/Taskfile.yml index 696d4d4..133c05f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,6 +1,6 @@ version: '3' -# Spinbox's colors, type, layout and mark: palette.toml and assets/avatar.png are the sources, and +# Spinbox's colors, type, layout and mark: palette.toml and assets/mark.png are the sources, and # dist/ and logos/ are what they make, committed, for the website, the dashboard and anything else # that draws Spinbox to copy. `task check` holds them to the sources. @@ -21,7 +21,7 @@ tasks: - go run ./cmd/palette -report mark: - desc: Cut the stack of disks out of the organization's avatar, without its wordmark, into logos/ at each size. + desc: Cut the stack of disks out of assets/mark.png, the mark as drawn, into logos/ at each size - reduced, never enlarged. cmds: - go run ./cmd/mark diff --git a/assets/avatar.png b/assets/avatar.png deleted file mode 100644 index aa8fa26..0000000 Binary files a/assets/avatar.png and /dev/null differ diff --git a/assets/mark.png b/assets/mark.png new file mode 100644 index 0000000..d128b39 Binary files /dev/null and b/assets/mark.png differ diff --git a/cmd/mark/main.go b/cmd/mark/main.go index 8a7efd9..813bfc3 100644 --- a/cmd/mark/main.go +++ b/cmd/mark/main.go @@ -1,7 +1,12 @@ -// Command mark cuts the spin-stack mark - the stack of disks - out of the organization's avatar -// (assets/avatar.png), without the wordmark under it, and writes it at the sizes the places that -// show it use, into logos/: on its own ground, square, for each to round as it rounds a tile. -// With -check it writes nothing and fails when logos/ is not what the avatar makes. +// Command mark cuts the spin-stack mark - the stack of disks - out of assets/mark.png, the mark as +// it was drawn: 1024 px on a clear ground, the stack some 450 of them. (The organization's avatar +// is the same mark shrunk to 460 px on the navy with the wordmark under it, the stack 240 px: cut +// from it, every logo larger than that, or on a 2x screen, was an enlargement.) +// It writes the mark at the sizes the places that show it use, into logos/: on the brand's navy, +// square, for each to round as it rounds a tile, and clear, for a light ground. Every size is the +// drawing reduced, never enlarged: a size the drawing is too small for fails. +// +// With -check it writes nothing and fails when logos/ is not what the drawing makes. package main import ( @@ -11,29 +16,34 @@ import ( "image" "image/color" "image/png" - "math" "os" "path/filepath" + "strconv" + "github.com/BurntSushi/toml" "golang.org/x/image/draw" ) -// logo is one file logos/ holds: the mark at a size something shows it at, on its own ground (a -// tile) or with the ground taken out (clear). +// logo is one file logos/ holds: the mark at a size something shows it at, on the navy (a tile) +// or on nothing (clear). type logo struct { size int clear bool } var sizes = map[string]logo{ - "mark-512.png": {512, false}, // the link preview, a dark hero - "mark-256.png": {256, false}, // a dark header - "mark-clear-512.png": {512, true}, // on any ground: a light page, a light hero - "mark-clear-256.png": {256, true}, // a light header - "favicon-64.png": {64, false}, // a browser's tab, whose ground is the browser's - "favicon-32.png": {32, false}, + "mark-512.png": {512, false}, // the link preview, a dark hero + "mark-256.png": {256, false}, // a dark header + "mark-clear-512.png": {512, true}, // on any ground: a light page, a light hero + "mark-clear-256.png": {256, true}, // a light header + "apple-touch-icon.png": {180, false}, // a phone's home screen + "favicon-64.png": {64, false}, // a browser's tab, whose ground is the browser's + "favicon-32.png": {32, false}, } +// margin is the square's side over the stack's longer one: a fifth of it around the stack. +const margin = 1.22 + func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "mark:", err) @@ -42,40 +52,42 @@ func main() { } func run() error { - src := flag.String("avatar", "assets/avatar.png", "the organization's avatar") + src := flag.String("mark", "assets/mark.png", "the mark as drawn, on a clear ground") + palette := flag.String("palette", "palette.toml", "the palette, whose brand navy is the tile's ground") out := flag.String("out", "logos", "where the mark is written") - check := flag.Bool("check", false, "write nothing; fail if logos/ is not what the avatar makes") + check := flag.Bool("check", false, "write nothing; fail if logos/ is not what the drawing makes") flag.Parse() + navy, err := ground(*palette) + if err != nil { + return err + } f, err := os.Open(*src) if err != nil { return err } defer f.Close() - avatar, err := png.Decode(f) + drawing, err := png.Decode(f) if err != nil { return err } - tile, err := cut(avatar) + stack, err := stackOf(drawing) if err != nil { - return err + return fmt.Errorf("%s: %w", *src, err) } - clear := unground(tile, avatar.At(avatar.Bounds().Min.X+4, avatar.Bounds().Min.Y+4)) + var stale []string for name, l := range sizes { - src := tile - if l.clear { - src = clear + img, err := cut(drawing, stack, l.size, l.clear, navy) + if err != nil { + return fmt.Errorf("%s: %w", name, err) } - dst := image.NewRGBA(image.Rect(0, 0, l.size, l.size)) - draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil) var b bytes.Buffer - if err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&b, dst); err != nil { + if err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&b, img); err != nil { return err } path := filepath.Join(*out, name) - have, err := os.ReadFile(path) - if err == nil && bytes.Equal(have, b.Bytes()) { + if have, err := os.ReadFile(path); err == nil && bytes.Equal(have, b.Bytes()) { continue } stale = append(stale, name) @@ -95,122 +107,55 @@ func run() error { return nil } -// cut finds the stack in the avatar - the first band of rows with anything but the ground in -// them, which ends at the gap above the wordmark - and centers it on a square of the ground, with -// a fifth of its size as margin. -func cut(avatar image.Image) (image.Image, error) { - b := avatar.Bounds() - ground := avatar.At(b.Min.X+4, b.Min.Y+4) - away := func(x, y int) bool { - r1, g1, b1, _ := avatar.At(x, y).RGBA() - r2, g2, b2, _ := ground.RGBA() - d := func(a, b uint32) int { - if a > b { - return int(a-b) >> 8 - } - return int(b-a) >> 8 - } - return d(r1, r2)+d(g1, g2)+d(b1, b2) > 40 +// ground is the palette's brand navy, the tile's ground and the dark page's. +func ground(palette string) (color.RGBA, error) { + var p struct{ Brand map[string]string } + if _, err := toml.DecodeFile(palette, &p); err != nil { + return color.RGBA{}, err } - rowHas := func(y int) bool { - for x := b.Min.X; x < b.Max.X; x++ { - if away(x, y) { - return true - } - } - return false - } - top := -1 - for y := b.Min.Y; y < b.Max.Y; y++ { - if rowHas(y) { - top = y - break - } + hex := p.Brand["navy"] + if len(hex) != 7 || hex[0] != '#' { + return color.RGBA{}, fmt.Errorf("%s: brand.navy %q is no #rrggbb", palette, hex) } - if top < 0 { - return nil, fmt.Errorf("the avatar is only its ground") - } - bottom, gap := top, 0 - for y := top; y < b.Max.Y && gap <= 6; y++ { - if rowHas(y) { - bottom, gap = y, 0 - } else { - gap++ - } + v, err := strconv.ParseUint(hex[1:], 16, 32) + if err != nil { + return color.RGBA{}, fmt.Errorf("%s: brand.navy %q is no #rrggbb", palette, hex) } - left, right := b.Max.X, b.Min.X - for y := top; y <= bottom; y++ { + return color.RGBA{uint8(v >> 16), uint8(v >> 8), uint8(v), 255}, nil +} + +// stackOf is the box of what the drawing draws: every pixel past a faint alpha, which leaves out +// the drawing's near-invisible haze. +func stackOf(drawing image.Image) (image.Rectangle, error) { + b := drawing.Bounds() + box := image.Rectangle{} + for y := b.Min.Y; y < b.Max.Y; y++ { for x := b.Min.X; x < b.Max.X; x++ { - if away(x, y) { - left, right = min(left, x), max(right, x) + if _, _, _, a := drawing.At(x, y).RGBA(); a>>8 > 16 { + box = box.Union(image.Rect(x, y, x+1, y+1)) } } } - stack := image.Rect(left-4, top-4, right+5, bottom+5) - side := int(float64(max(stack.Dx(), stack.Dy())) * 1.22) - tile := image.NewRGBA(image.Rect(0, 0, side, side)) - draw.Draw(tile, tile.Bounds(), &image.Uniform{color.RGBAModel.Convert(ground)}, image.Point{}, draw.Src) - at := image.Pt((side-stack.Dx())/2, (side-stack.Dy())/2) - draw.Draw(tile, stack.Sub(stack.Min).Add(at), avatar, stack.Min, draw.Src) - return tile, nil + if box.Empty() { + return box, fmt.Errorf("it draws nothing: no pixel is more than faintly opaque") + } + return box, nil } -// unground takes the avatar's ground out of the tile: the ground is what a fill from the -// tile's edges reaches through pixels within a few levels of it, and everything else - the -// disks, the ring, the dark seams between the disks, which are darker than the ground - stays -// opaque in its own color. The ground is textured, so a pixel near its edge fades by how far it -// is from the ground rather than being cut, which keeps the silhouette's antialiasing. -func unground(tile image.Image, ground color.Color) image.Image { - const near, far = 5.0, 11.0 // distance to the ground, in 8-bit levels: ground under near, opaque past far - gr, gg, gb, _ := ground.RGBA() - dist := func(c color.Color) float64 { - r, g, b, _ := c.RGBA() - dr, dg, db := float64(r>>8)-float64(gr>>8), float64(g>>8)-float64(gg>>8), float64(b>>8)-float64(gb>>8) - return math.Sqrt(dr*dr + dg*dg + db*db) - } - b := tile.Bounds() - w, h := b.Dx(), b.Dy() - isGround := make([]bool, w*h) - var queue []image.Point - push := func(x, y int) { - if x < 0 || y < 0 || x >= w || y >= h || isGround[y*w+x] { - return - } - if dist(tile.At(b.Min.X+x, b.Min.Y+y)) >= far { - return - } - isGround[y*w+x] = true - queue = append(queue, image.Pt(x, y)) - } - for x := range w { - push(x, 0) - push(x, h-1) - } - for y := range h { - push(0, y) - push(w-1, y) - } - for len(queue) > 0 { - p := queue[len(queue)-1] - queue = queue[:len(queue)-1] - push(p.X+1, p.Y) - push(p.X-1, p.Y) - push(p.X, p.Y+1) - push(p.X, p.Y-1) - } - out := image.NewNRGBA(b) - for y := range h { - for x := range w { - c := color.NRGBAModel.Convert(tile.At(b.Min.X+x, b.Min.Y+y)).(color.NRGBA) - if isGround[y*w+x] { - d := dist(c) - if d <= near { - continue - } - c.A = uint8(255 * (d - near) / (far - near)) - } - out.SetNRGBA(b.Min.X+x, b.Min.Y+y, c) - } - } - return out +// cut is the stack centered on a square of size pixels, a fifth of its size around it, on the +// navy or on nothing. +func cut(drawing image.Image, stack image.Rectangle, size int, clear bool, navy color.RGBA) (*image.RGBA, error) { + side := float64(max(stack.Dx(), stack.Dy())) * margin + scale := float64(size) / side + if scale > 1 { + return nil, fmt.Errorf("%d px would enlarge the drawing %.2f times: its stack is %d px", size, scale, max(stack.Dx(), stack.Dy())) + } + w, h := int(float64(stack.Dx())*scale+0.5), int(float64(stack.Dy())*scale+0.5) + at := image.Pt((size-w)/2, (size-h)/2) + img := image.NewRGBA(image.Rect(0, 0, size, size)) + if !clear { + draw.Draw(img, img.Bounds(), &image.Uniform{navy}, image.Point{}, draw.Src) + } + draw.CatmullRom.Scale(img, image.Rectangle{at, at.Add(image.Pt(w, h))}, drawing, stack, draw.Over, nil) + return img, nil } diff --git a/logos/apple-touch-icon.png b/logos/apple-touch-icon.png new file mode 100644 index 0000000..ca0a75b Binary files /dev/null and b/logos/apple-touch-icon.png differ diff --git a/logos/favicon-32.png b/logos/favicon-32.png index 346c440..02439bd 100644 Binary files a/logos/favicon-32.png and b/logos/favicon-32.png differ diff --git a/logos/favicon-64.png b/logos/favicon-64.png index 42f9c0a..ed3bcd8 100644 Binary files a/logos/favicon-64.png and b/logos/favicon-64.png differ diff --git a/logos/mark-256.png b/logos/mark-256.png index 675024d..c2ffaac 100644 Binary files a/logos/mark-256.png and b/logos/mark-256.png differ diff --git a/logos/mark-512.png b/logos/mark-512.png index 60087d7..e4a44d1 100644 Binary files a/logos/mark-512.png and b/logos/mark-512.png differ diff --git a/logos/mark-clear-256.png b/logos/mark-clear-256.png index 775bcb5..3cb64ad 100644 Binary files a/logos/mark-clear-256.png and b/logos/mark-clear-256.png differ diff --git a/logos/mark-clear-512.png b/logos/mark-clear-512.png index 1921cea..8f163a0 100644 Binary files a/logos/mark-clear-512.png and b/logos/mark-clear-512.png differ