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:
2026-04-21 15:21:21 -05:00
parent e830e5da50
commit f77da87eaa
21 changed files with 1533 additions and 7 deletions

View 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())