feat: phase 1 public site skeleton — layout, routes, CSS, logo pipeline
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.
This commit is contained in:
199
scripts/generate_static_assets.py
Normal file
199
scripts/generate_static_assets.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user