Files
projax/cmd/icongen/main.go
mAi 1d5db0fe7b feat(phase 3j pwa): manifest + service worker + icons → installable PWA
- 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
2026-05-15 19:32:48 +02:00

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
}