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
-
+
+
+