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>
284 lines
11 KiB
Python
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())
|