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