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

61
app/services/posts.py Normal file
View File

@@ -0,0 +1,61 @@
"""Blog post service layer.
Phase 1 ships a stub: :meth:`PostService.list_published` returns an empty
list so the home page renders cleanly without a database. Phase 2 will
replace the stub with a real SQLite-backed implementation. The public
method signature and return type (`list[PostSummary]`) are frozen now so
route and template code written in Phase 1 won't need to change when the
DB arrives.
"""
from __future__ import annotations
from app.models.posts import PostSummary
class PostService:
"""Read-side service for published blog posts.
The service is intentionally stateless in Phase 1. Phase 2 will give
it a SQLite connection (or connection factory) via constructor
injection; callers obtain an instance through :func:`get_post_service`
so the swap is transparent to the routes that depend on it.
"""
def list_published(self, limit: int = 20) -> list[PostSummary]:
"""Return up to ``limit`` published posts, most recent first.
Parameters
----------
limit:
Maximum number of summaries to return. Kept in the signature
now (even though the stub ignores it) so Phase 2's real
implementation is a drop-in replacement.
Returns
-------
list[PostSummary]
Currently always an empty list. The template treats an empty
list as the "no posts yet" state.
"""
# Phase 1 stub: no DB, no posts. Phase 2 will issue a parameterized
# SELECT against the `posts` table filtered by status='published'
# and ordered by published_at DESC.
return []
# Module-level singleton. The service is stateless in Phase 1, so one
# instance is safe to share across requests. Phase 2 may relocate this
# behind a factory if per-request scoping becomes useful.
_post_service: PostService = PostService()
def get_post_service() -> PostService:
"""Return the shared :class:`PostService` for FastAPI dependency injection.
Keeping this as a module-level function (rather than instantiating a
fresh service on every request) means FastAPI's ``Depends`` wiring
pays no construction cost on the hot path, and tests can override the
dependency via ``app.dependency_overrides[get_post_service]``.
"""
return _post_service