Files
chicken_babies_site/scripts/generate_static_assets.py
Phillip Tarrant f5098c05f5 fix: brand contrast — chick-only header mark, ink callouts, nav polish
Phase 1 brand palette reused --c-sky for both the header background and
the word "Babies" inside the logo art, erasing it visually. Same class
of problem hit the contact page mailto callout.

- Split logo into chick mark + HTML site title so the wordmark colors
  no longer need to coexist with the header surface. Generator gains
  build_logo_mark_{png,webp} with a widest-gap column scan to crop.
- Header moves to --c-wheat; nav active state flips to ink pill with
  cream text; muted Shop link reads as coming-soon (italic + dim +
  not-allowed).
- Contact page mailto callout reskinned to ink/cream (strong CTA) and
  form note shifts from pale sky-deep to ink at 70% opacity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:07:30 -05:00

284 lines
11 KiB
Python

"""Generate static image assets (logo + favicons) from the brand source.
This script is the single source of truth for every image under
``app/static/img/``. Running it re-derives:
- ``logo.png`` — 256px tall, transparent RGBA
- ``logo.webp`` — same size, WebP quality=82, method=6
- ``logo-mark.png`` — chick-only mark, 128px tall, transparent RGBA
- ``logo-mark.webp`` — same, WebP quality=82, method=6
- ``favicon.ico`` — multi-size 16/32/48 from a square crop
- ``apple-touch-icon.png`` — 180x180 with a cream background
Running it is reproducible and idempotent. The generated files are
committed to the repo so a fresh clone can serve the site without a
build step, but they should be regenerated (by running this script) any
time the brand source in ``Logo/`` changes.
Usage
-----
python scripts/generate_static_assets.py
The script assumes it is run from the repository root. It resolves paths
relative to this file's location so the working directory does not
matter in practice.
"""
from __future__ import annotations
import sys
from pathlib import Path
from PIL import Image
# --- Configuration constants ------------------------------------------------
# Single-source-of-truth values. Tweaking any of these is intended to be
# the *only* change needed to retune the assets — no other code edits.
# Paths are resolved relative to the repository root (the parent of the
# directory this script lives in) so the script can be run from anywhere.
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
_SOURCE_LOGO: Path = _REPO_ROOT / "Logo" / "chicken babies r us.png"
_STATIC_IMG_DIR: Path = _REPO_ROOT / "app" / "static" / "img"
# Target output sizes.
_LOGO_TARGET_HEIGHT_PX: int = 256 # 2x the 48px display height in the header.
_LOGO_MARK_TARGET_HEIGHT_PX: int = 128 # ~2x the 56px header-icon display size.
_WEBP_QUALITY: int = 82
_WEBP_METHOD: int = 6 # 0 = fastest, 6 = best compression.
_FAVICON_SIZES: tuple[tuple[int, int], ...] = ((16, 16), (32, 32), (48, 48))
_APPLE_TOUCH_SIZE: int = 180
# Cream background that matches --c-cream in site.css, so the icon does
# not show as transparent (iOS squares this asset against a white/black
# home screen).
_APPLE_TOUCH_BG: tuple[int, int, int, int] = (0xFA, 0xF3, 0xE7, 0xFF)
class StaticAssetBuilder:
"""Build every derived static image from a single PNG source.
Encapsulated as a class so each transformation step is an
independently-testable method and state (e.g. the loaded source
image) is shared without relying on module globals.
"""
def __init__(self, source_path: Path, output_dir: Path) -> None:
"""Load the source image and prepare the output directory.
Parameters
----------
source_path:
Path to the brand-source PNG. Must exist on disk.
output_dir:
Directory where every generated asset is written. Created if
it does not already exist.
"""
if not source_path.is_file():
raise FileNotFoundError(
f"Source logo not found at {source_path}. "
"Re-check the Logo/ directory."
)
self._source_path = source_path
self._output_dir = output_dir
self._output_dir.mkdir(parents=True, exist_ok=True)
# Always load as RGBA so alpha compositing (apple-touch-icon
# background) and WebP export behave predictably.
with Image.open(source_path) as raw:
self._source: Image.Image = raw.convert("RGBA")
# ------------------------------------------------------------------
# Individual asset builders
# ------------------------------------------------------------------
def build_logo_png(self) -> Path:
"""Write the RGBA PNG version of the logo."""
resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX)
out_path = self._output_dir / "logo.png"
resized.save(out_path, format="PNG", optimize=True)
return out_path
def build_logo_webp(self) -> Path:
"""Write the WebP version of the logo at the same pixel size."""
resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX)
out_path = self._output_dir / "logo.webp"
resized.save(
out_path,
format="WEBP",
quality=_WEBP_QUALITY,
method=_WEBP_METHOD,
)
return out_path
def build_logo_mark_png(self) -> Path:
"""Write the chick-only mark as RGBA PNG."""
mark = self._aspect_resize(
self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX
)
out_path = self._output_dir / "logo-mark.png"
mark.save(out_path, format="PNG", optimize=True)
return out_path
def build_logo_mark_webp(self) -> Path:
"""Write the chick-only mark as WebP."""
mark = self._aspect_resize(
self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX
)
out_path = self._output_dir / "logo-mark.webp"
mark.save(
out_path,
format="WEBP",
quality=_WEBP_QUALITY,
method=_WEBP_METHOD,
)
return out_path
def build_favicon(self) -> Path:
"""Write the multi-size ICO favicon built from a square crop.
The source logo is landscape, so we center it on a transparent
square canvas sized to the longer dimension before downsizing.
"""
square = self._square_pad(self._source, background=(0, 0, 0, 0))
out_path = self._output_dir / "favicon.ico"
# Pillow's .save(..., format="ICO", sizes=...) writes every
# requested size into one .ico file; browsers pick the best one.
square.save(out_path, format="ICO", sizes=list(_FAVICON_SIZES))
return out_path
def build_apple_touch_icon(self) -> Path:
"""Write the 180x180 apple-touch-icon with a cream background.
iOS composites this icon against the home-screen background, so
a transparent PNG would bleed through. We paint the cream brand
background behind the logo ourselves to lock the look.
"""
square = self._square_pad(self._source, background=_APPLE_TOUCH_BG)
icon = square.resize(
(_APPLE_TOUCH_SIZE, _APPLE_TOUCH_SIZE),
resample=Image.Resampling.LANCZOS,
)
out_path = self._output_dir / "apple-touch-icon.png"
icon.save(out_path, format="PNG", optimize=True)
return out_path
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _aspect_resize(image: Image.Image, *, height: int) -> Image.Image:
"""Return a new image scaled to the given height, aspect preserved."""
src_w, src_h = image.size
# Guard against degenerate input; ratio math would divide by zero.
if src_h == 0:
raise ValueError("Source image has zero height.")
new_w = max(1, round(src_w * (height / src_h)))
return image.resize((new_w, height), resample=Image.Resampling.LANCZOS)
def _crop_chick_mark(self) -> Image.Image:
"""Return a copy of the source cropped to just the chick artwork.
Strategy: scan the source column-by-column for transparency,
find the widest contiguous transparent "gap" between opaque
columns, and treat the content to the left of that gap as the
chick. This is robust to future logo tweaks (new fonts, wider
tracking, shifted text) because it does not rely on hardcoded
pixel coordinates — only on the visual fact that the brand
mark and wordmark are separated by a wider gap than any gap
internal to either side.
"""
img = self._source
alpha = img.split()[-1]
width, height = img.size
# Per-column "has any opaque pixel" flags. getextrema on a 1px
# band returns (min, max); max > 0 means at least one opaque
# pixel. This is O(width) Pillow calls — fast enough here.
opaque = [
alpha.crop((x, 0, x + 1, height)).getextrema()[1] > 0
for x in range(width)
]
first_opaque = next((x for x, o in enumerate(opaque) if o), None)
last_opaque = next(
(x for x in range(width - 1, -1, -1) if opaque[x]), None
)
if first_opaque is None or last_opaque is None:
raise ValueError("Source logo has no opaque pixels.")
# Enumerate transparent runs strictly between first/last opaque.
gaps: list[tuple[int, int]] = [] # (gap_start, gap_end_exclusive)
run_start: int | None = None
for x in range(first_opaque, last_opaque + 1):
if not opaque[x] and run_start is None:
run_start = x
elif opaque[x] and run_start is not None:
gaps.append((run_start, x))
run_start = None
if not gaps:
raise ValueError(
"No transparent gap found between chick and wordmark; "
"cannot split the mark automatically."
)
gap_start, _ = max(gaps, key=lambda g: g[1] - g[0])
chick_slice = img.crop((first_opaque, 0, gap_start, height))
# Trim vertical whitespace so the resulting icon sits flush.
inner = chick_slice.getbbox()
if inner is None:
raise ValueError("Detected chick region is empty.")
return chick_slice.crop(inner)
@staticmethod
def _square_pad(
image: Image.Image,
*,
background: tuple[int, int, int, int],
) -> Image.Image:
"""Center the image on a square canvas of the given background.
We pad rather than crop so the whole mark survives at every
favicon size. For a landscape logo, padding vertically preserves
the design at the cost of a little empty space top and bottom —
the correct trade-off for a recognition-first icon.
"""
src_w, src_h = image.size
side = max(src_w, src_h)
canvas = Image.new("RGBA", (side, side), background)
offset = ((side - src_w) // 2, (side - src_h) // 2)
canvas.paste(image, offset, mask=image if image.mode == "RGBA" else None)
return canvas
def main() -> int:
"""Generate every static asset and report the output paths.
Returns a shell exit code (0 on success) so the script can be chained
into CI or build scripts without wrapping.
"""
builder = StaticAssetBuilder(_SOURCE_LOGO, _STATIC_IMG_DIR)
generated: list[Path] = [
builder.build_logo_png(),
builder.build_logo_webp(),
builder.build_logo_mark_png(),
builder.build_logo_mark_webp(),
builder.build_favicon(),
builder.build_apple_touch_icon(),
]
# Emit a short human-readable report. Using print (not structlog)
# because this is a one-shot developer tool, not runtime app code.
print("Generated static assets:")
for path in generated:
rel = path.relative_to(_REPO_ROOT)
print(f" - {rel}")
return 0
if __name__ == "__main__":
sys.exit(main())