Ship base Jinja layout (header/nav/main/footer with skip link and aria-current), mobile-first single-file CSS using the ROADMAP palette tokens, and four public routes: /, /about, /contact, /shop. Blog index renders via a stable PostService.list_published() stub returning [] — Phase 2 only swaps the body. About is static placeholder copy, /contact ships an inert form plus a mailto: link driven by ADMIN_CONTACT_EMAIL, /shop shows a "Coming soon" card. Adds a Pillow-based scripts/generate_static_assets.py producing resized logo PNG + WebP, multi-size favicon.ico, and a 180x180 apple-touch-icon on a cream background. Outputs committed for a reproducible build. Also ship docs/MANUAL_TESTING.md with per-route / responsive / a11y / static- asset checklists, and mark Phase 1 complete in docs/ROADMAP.md.
200 lines
7.7 KiB
Python
200 lines
7.7 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
|
|
- ``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.
|
|
_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_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)
|
|
|
|
@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_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())
|