diff --git a/README.md b/README.md index e62880f..8171425 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.svg` | `logos/`: the stack of disks, traced from the organization's avatar (`assets/avatar.png`) as a vector: `mark.svg`, the tile on its own navy, for a dark ground, and `mark-clear.svg` for a light one; PNGs of each drawn at 1024, 512 and 256, the favicon at 64 and 32 and the touch icon at 180; `trace.png`, the avatar beside the trace. `task check` fails when the trace moves away from the avatar | ``` task # palette and mark: check, and write dist/ and logos/ diff --git a/Taskfile.yml b/Taskfile.yml index 696d4d4..9c4f8fd 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.svg 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: Draw the mark from assets/mark.svg, the avatar's stack traced as a vector, into logos/ - the SVG, tile and clear, and a PNG at each size. cmds: - go run ./cmd/mark diff --git a/assets/mark.svg b/assets/mark.svg new file mode 100644 index 0000000..bce56e2 --- /dev/null +++ b/assets/mark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/mark/main.go b/cmd/mark/main.go index 8a7efd9..5dea914 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 draws the spin-stack mark - the stack of disks - from assets/mark.svg, the +// organization's avatar traced as a vector, into logos/: the SVG itself, on its own ground (the +// tile) and with the ground taken out (clear), and PNGs at the sizes the places that cannot take +// an SVG show it at, each drawn at that size rather than scaled from a smaller one. The avatar is +// 460 px, its stack 240 of them: anything cut from it and shown larger, or on a 2x screen, was +// an enlargement. +// +// With -check it writes nothing and fails when logos/ is not what the SVG makes, or when the +// trace has moved further from the avatar than it is now. package main import ( @@ -9,31 +14,47 @@ import ( "flag" "fmt" "image" - "image/color" "image/png" - "math" "os" "path/filepath" + "regexp" + "strconv" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" "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, on its ground (a tile) or 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-1024.png": {1024, false}, // the organization's avatar, a print + "mark-512.png": {512, false}, // the link preview, a dark hero + "mark-256.png": {256, false}, // a dark header + "mark-clear-1024.png": {1024, true}, + "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}, + "favicon-64.png": {64, false}, // a browser's tab, whose ground is the browser's + "favicon-32.png": {32, false}, } +// drift is how far the trace may be from the avatar: the mean difference per channel, in 8-bit +// levels, over the square the SVG draws, the wordmark under the stack left out. The trace is +// flatter than the avatar - no texture in the ground, no glow - and measures 10.3; without its +// middle disk it is 17.1. A change that takes it past this is a different mark. +const drift = 12.0 + +// ground is the tile's group: the navy and the shadow under the stack. +var ground = regexp.MustCompile(`(?s)\s*.*?`) + +// origin is where the SVG's square is in the avatar: its outer group's translate, negated. +var origin = regexp.MustCompile(``) + func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "mark:", err) @@ -42,40 +63,62 @@ func main() { } func run() error { - src := flag.String("avatar", "assets/avatar.png", "the organization's avatar") + src := flag.String("svg", "assets/mark.svg", "the mark") + avatar := flag.String("avatar", "assets/avatar.png", "the organization's avatar, which the mark traces") 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 mark makes or the trace left the avatar") flag.Parse() - f, err := os.Open(*src) + tile, err := os.ReadFile(*src) if err != nil { return err } - defer f.Close() - avatar, err := png.Decode(f) + if !ground.Match(tile) { + return fmt.Errorf("%s has no : a clear mark is the tile without it", *src) + } + clear := ground.ReplaceAll(tile, nil) + traced, err := square(tile, *avatar) + if err != nil { + return err + } + + if *check { + d, err := distance(tile, traced) + if err != nil { + return err + } + if d > drift { + return fmt.Errorf("%s is %.1f from %s, past %.1f: it no longer traces the avatar", *src, d, *avatar, drift) + } + } + + side, err := sideOf(tile) if err != nil { return err } - tile, err := cut(avatar) + pair, err := trace(tile, traced, side) if err != nil { return err } - clear := unground(tile, avatar.At(avatar.Bounds().Min.X+4, avatar.Bounds().Min.Y+4)) - var stale []string + files := map[string][]byte{"mark.svg": tile, "mark-clear.svg": clear, "trace.png": pair} for name, l := range sizes { - src := tile + svg := tile if l.clear { - src = clear + svg = clear } - 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 { + img, err := render(svg, l.size) + if err != nil { return err } + if files[name], err = encode(img); err != nil { + return err + } + } + + var stale []string + for name, b := range files { 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) { continue } stale = append(stale, name) @@ -85,7 +128,7 @@ func run() error { if err := os.MkdirAll(*out, 0o755); err != nil { return err } - if err := os.WriteFile(path, b.Bytes(), 0o644); err != nil { + if err := os.WriteFile(path, b, 0o644); err != nil { return err } } @@ -95,122 +138,134 @@ 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) { +// render draws an SVG on a square of size pixels. Its viewBox starts at 0 0: the rasterizer +// shifts a drawing whose viewBox does not by an amount that changes with the size - the favicon +// came out empty at 32 - so the offset into the avatar is a translate instead. +func render(svg []byte, size int) (*image.RGBA, error) { + icon, err := oksvg.ReadIconStream(bytes.NewReader(svg), oksvg.StrictErrorMode) + if err != nil { + return nil, err + } + if icon.ViewBox.X != 0 || icon.ViewBox.Y != 0 { + return nil, fmt.Errorf("the mark's viewBox starts at %g %g, not 0 0: put the offset in a translate", icon.ViewBox.X, icon.ViewBox.Y) + } + icon.SetTarget(0, 0, float64(size), float64(size)) + img := image.NewRGBA(image.Rect(0, 0, size, size)) + icon.Draw(rasterx.NewDasher(size, size, rasterx.NewScannerGV(size, size, img, img.Bounds())), 1) + return img, nil +} + +// square is the part of the avatar the mark traces: from its translate, negated, as wide as its +// viewBox. The SVG is in the avatar's coordinates, so drawn at one pixel a unit the two are the +// same pixels. +func square(tile []byte, avatarPath string) (image.Image, error) { + f, err := os.Open(avatarPath) + if err != nil { + return nil, err + } + defer f.Close() + avatar, err := png.Decode(f) + if err != nil { + return nil, err + } + m := origin.FindSubmatch(tile) + if m == nil { + return nil, fmt.Errorf("the mark has no translate to the avatar's coordinates") + } + x0, _ := strconv.Atoi(string(m[1])) + y0, _ := strconv.Atoi(string(m[2])) + side, err := sideOf(tile) + if err != nil { + return nil, err + } + // The wordmark under the stack is not the mark: from the first rows of ground after the stack + // down, the square is the ground, as the tile is. 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 - } - rowHas := func(y int) bool { + gr, gg, gb, _ := ground.RGBA() + plain := func(y int) bool { for x := b.Min.X; x < b.Max.X; x++ { - if away(x, y) { - return true + r, g, bl, _ := avatar.At(x, y).RGBA() + if abs(r, gr)+abs(g, gg)+abs(bl, gb) > 40 { + return false } } - return false + return true } - top := -1 + end, gap, seen := b.Max.Y, 0, false for y := b.Min.Y; y < b.Max.Y; y++ { - if rowHas(y) { - top = y + switch { + case !plain(y): + seen, gap = true, 0 + case seen: + gap++ + } + if gap > 6 { + end = y break } } - if top < 0 { - return nil, fmt.Errorf("the avatar is only its ground") + out := image.NewRGBA(image.Rect(x0, y0, x0+side, y0+side)) + draw.Draw(out, out.Bounds(), avatar, out.Bounds().Min, draw.Src) + draw.Draw(out, image.Rect(x0, end, x0+side, y0+side), image.NewUniform(ground), image.Point{}, draw.Src) + return out, nil +} + +// sideOf is the side of the mark's square viewBox. +func sideOf(tile []byte) (int, error) { + icon, err := oksvg.ReadIconStream(bytes.NewReader(tile), oksvg.StrictErrorMode) + if err != nil { + return 0, err } - bottom, gap := top, 0 - for y := top; y < b.Max.Y && gap <= 6; y++ { - if rowHas(y) { - bottom, gap = y, 0 - } else { - gap++ - } + return int(icon.ViewBox.W), nil +} + +// distance is the mean difference per channel, in 8-bit levels, between the tile drawn at one +// pixel a unit and the square of the avatar it traces. +func distance(tile []byte, traced image.Image) (float64, error) { + b := traced.Bounds() + drawn, err := render(tile, b.Dx()) + if err != nil { + return 0, err } - left, right := b.Max.X, b.Min.X - for y := top; y <= bottom; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - if away(x, y) { - left, right = min(left, x), max(right, x) - } + var sum float64 + for y := range b.Dy() { + for x := range b.Dx() { + r1, g1, b1, _ := drawn.At(x, y).RGBA() + r2, g2, b2, _ := traced.At(b.Min.X+x, b.Min.Y+y).RGBA() + sum += abs(r1, r2) + abs(g1, g2) + abs(b1, b2) } } - 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 + return sum / float64(b.Dx()*b.Dy()*3), 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) - } +// trace is the avatar's square and the mark beside it, each twice its size: the one enlarged, as +// anything cut from the avatar was, the other drawn at that size. What a change to the trace looks +// like next to what it traces, for whoever reviews it. +func trace(tile []byte, traced image.Image, side int) ([]byte, error) { + const gap = 16 + s := 2 * side + out := image.NewRGBA(image.Rect(0, 0, 2*s+gap, s)) + draw.Draw(out, out.Bounds(), image.White, image.Point{}, draw.Src) + draw.CatmullRom.Scale(out, image.Rect(0, 0, s, s), traced, traced.Bounds(), draw.Src, nil) + drawn, err := render(tile, s) + if err != nil { + return nil, err + } + draw.Draw(out, image.Rect(s+gap, 0, 2*s+gap, s), drawn, image.Point{}, draw.Src) + return encode(out) +} + +func encode(img image.Image) ([]byte, error) { + var b bytes.Buffer + err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&b, img) + return b.Bytes(), err +} + +func abs(a, b uint32) float64 { + a, b = a>>8, b>>8 + if a > b { + return float64(a - b) } - return out + return float64(b - a) } diff --git a/cmd/mark/main_test.go b/cmd/mark/main_test.go new file mode 100644 index 0000000..e918156 --- /dev/null +++ b/cmd/mark/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "bytes" + "os" + "regexp" + "testing" +) + +func TestTheMarkTracesTheAvatar(t *testing.T) { + tile, err := os.ReadFile("../../assets/mark.svg") + if err != nil { + t.Fatal(err) + } + middle := regexp.MustCompile(`(?s) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logos/mark.svg b/logos/mark.svg new file mode 100644 index 0000000..bce56e2 --- /dev/null +++ b/logos/mark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logos/trace.png b/logos/trace.png new file mode 100644 index 0000000..ccb6e61 Binary files /dev/null and b/logos/trace.png differ diff --git a/screens.html b/screens.html index c045aec..81f85c7 100644 --- a/screens.html +++ b/screens.html @@ -4,7 +4,9 @@ Spinbox screens - + + +