- web/static/manifest.webmanifest: name/short_name/start_url=/dashboard/ display=standalone/theme_color/background_color + three icons (192, 512, 512-maskable with ~12% safe-zone padding) - web/static/sw.js: minimal SW — install caches /static/* shell assets, fetch is network-first with cache fallback on GETs only, skips /mcp/ and non-GETs entirely. CACHE_NAME versioned for clean activate-time prune. - cmd/icongen: stdlib-only generator that produces the three PNG icons from a stylised "p" monogram. Run once at brand-change, commit output. - web.init() registers .webmanifest → application/manifest+json with mime.AddExtensionType so Chrome accepts the manifest at all - layout.tmpl + login.tmpl: manifest link, apple-touch-icon, theme-color, apple-mobile-web-app-* metas, inline SW-register on load (silent on failure — older browsers still work) - design.md gets §"PWA install (Phase 3j)"; CLAUDE.md "Out of scope" drops the Phase-3j line and adds push/background-sync as the remaining Otto-PWA territory - 4 new tests cover manifest MIME, sw.js delivery, all 3 icons, layout meta tags
129 lines
3.8 KiB
Go
129 lines
3.8 KiB
Go
// icongen produces the three PNG icons projax needs for the PWA manifest.
|
|
// Run once at branch-cut, commit the output PNGs into web/static/, and forget
|
|
// this tool until the brand changes. Stdlib-only — no external image libs.
|
|
//
|
|
// go run ./cmd/icongen
|
|
//
|
|
// Output files (overwritten):
|
|
// web/static/icon-192.png
|
|
// web/static/icon-512.png
|
|
// web/static/icon-maskable.png (with 10% safe-zone padding)
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"os"
|
|
)
|
|
|
|
func main() {
|
|
cases := []struct {
|
|
name string
|
|
size int
|
|
padding int // safe-zone padding for the maskable variant
|
|
}{
|
|
{"web/static/icon-192.png", 192, 0},
|
|
{"web/static/icon-512.png", 512, 0},
|
|
{"web/static/icon-maskable.png", 512, 64}, // ~12% safe area
|
|
}
|
|
for _, c := range cases {
|
|
img := renderIcon(c.size, c.padding)
|
|
f, err := os.Create(c.name)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "create:", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := png.Encode(f, img); err != nil {
|
|
fmt.Fprintln(os.Stderr, "encode:", err)
|
|
os.Exit(1)
|
|
}
|
|
_ = f.Close()
|
|
fmt.Println("wrote", c.name)
|
|
}
|
|
}
|
|
|
|
// renderIcon draws a stylised "p" monogram inside a rounded-square dark
|
|
// background. padding is the safe-zone inset (icon content shrinks to make
|
|
// room for the maskable cutout regions).
|
|
func renderIcon(size, padding int) image.Image {
|
|
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
|
bg := color.RGBA{0x1a, 0x1a, 0x1a, 0xff}
|
|
fg := color.RGBA{0xe0, 0xe0, 0xe0, 0xff}
|
|
accent := color.RGBA{0x2f, 0x5d, 0x9e, 0xff}
|
|
|
|
// Background fill.
|
|
fillRect(img, image.Rect(0, 0, size, size), bg)
|
|
|
|
// Rounded-corner mask only on the non-maskable variants. For maskable
|
|
// PWA icons the system clips to its own mask shape, so the full square
|
|
// must be filled (the padding gives the system room to clip).
|
|
if padding == 0 {
|
|
drawRoundedSquare(img, size, bg)
|
|
}
|
|
|
|
// Inset by padding so the "p" sits inside the safe zone.
|
|
inset := padding
|
|
content := image.Rect(inset, inset, size-inset, size-inset)
|
|
|
|
// Letterform: a stylised "p". Composed of three rectangles + a circle
|
|
// approximation built from filled disks.
|
|
cw := content.Dx() // content width
|
|
ch := content.Dy()
|
|
stemW := cw / 8
|
|
bowlH := ch * 2 / 5
|
|
stemX := content.Min.X + cw*5/16
|
|
stemTopY := content.Min.Y + ch/6
|
|
stemBotY := content.Max.Y - ch/8
|
|
|
|
// Vertical stem.
|
|
fillRect(img, image.Rect(stemX, stemTopY, stemX+stemW, stemBotY), fg)
|
|
// Bowl top.
|
|
bowlLeft := stemX
|
|
bowlRight := content.Min.X + cw*12/16
|
|
bowlTop := stemTopY
|
|
bowlBot := bowlTop + bowlH
|
|
fillRect(img, image.Rect(bowlLeft, bowlTop, bowlRight, bowlTop+stemW), fg)
|
|
// Bowl bottom of the closed loop.
|
|
fillRect(img, image.Rect(bowlLeft, bowlBot-stemW, bowlRight, bowlBot), fg)
|
|
// Bowl right side.
|
|
fillRect(img, image.Rect(bowlRight-stemW, bowlTop, bowlRight, bowlBot), fg)
|
|
|
|
// Accent stripe at the bottom (projax's blue) for branding.
|
|
stripeTop := content.Max.Y - ch/16
|
|
fillRect(img, image.Rect(content.Min.X, stripeTop, content.Max.X, content.Max.Y), accent)
|
|
|
|
return img
|
|
}
|
|
|
|
func fillRect(img *image.RGBA, r image.Rectangle, c color.Color) {
|
|
r = r.Intersect(img.Bounds())
|
|
for y := r.Min.Y; y < r.Max.Y; y++ {
|
|
for x := r.Min.X; x < r.Max.X; x++ {
|
|
img.Set(x, y, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// drawRoundedSquare punches corners of the canvas to transparent so the icon
|
|
// reads as a tile rather than a hard-edged square. Only used on the
|
|
// non-maskable variants — the maskable system clips with its own mask shape.
|
|
func drawRoundedSquare(img *image.RGBA, size int, bg color.RGBA) {
|
|
radius := size / 10
|
|
transparent := color.RGBA{0, 0, 0, 0}
|
|
for y := 0; y < radius; y++ {
|
|
for x := 0; x < radius; x++ {
|
|
dx := radius - x
|
|
dy := radius - y
|
|
if dx*dx+dy*dy > radius*radius {
|
|
img.Set(x, y, transparent)
|
|
img.Set(size-1-x, y, transparent)
|
|
img.Set(x, size-1-y, transparent)
|
|
img.Set(size-1-x, size-1-y, transparent)
|
|
}
|
|
}
|
|
}
|
|
_ = bg
|
|
}
|