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

@@ -8,13 +8,26 @@ via ``app.main:app``.
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import structlog import structlog
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app import __version__ from app import __version__
from app.config import get_settings from app.config import get_settings
from app.logging_config import configure_logging from app.logging_config import configure_logging
from app.routes.health import router as health_router from app.routes.health import router as health_router
from app.routes.public import router as public_router
# Resolve the package root once so template / static paths stay correct
# regardless of the current working directory at startup (running under
# uvicorn from the repo root vs. pytest from anywhere vs. inside Docker).
_PACKAGE_ROOT: Path = Path(__file__).resolve().parent
_TEMPLATES_DIR: Path = _PACKAGE_ROOT / "templates"
_STATIC_DIR: Path = _PACKAGE_ROOT / "static"
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -24,7 +37,11 @@ def create_app() -> FastAPI:
- Load validated configuration via :func:`get_settings`. - Load validated configuration via :func:`get_settings`.
- Initialize structured logging *before* any logger is used. - Initialize structured logging *before* any logger is used.
- Instantiate FastAPI with canonical title + version. - Instantiate FastAPI with canonical title + version.
- Register routers (Phase 0: only ``/healthz``). - Mount the ``/static`` directory for CSS, JS, and image assets.
- Attach the shared :class:`Jinja2Templates` to ``app.state`` so route
dependencies can retrieve it without a circular import on this
module.
- Register routers (Phase 1: health + public).
- Emit a single ``app_started`` structured log event. - Emit a single ``app_started`` structured log event.
""" """
# Parse + validate configuration first so a bad environment fails fast # Parse + validate configuration first so a bad environment fails fast
@@ -44,9 +61,27 @@ def create_app() -> FastAPI:
redoc_url="/redoc", redoc_url="/redoc",
) )
# Serve CSS, images, and other static assets from app/static. The
# `check_dir=False` would let Starlette skip the existence check; we
# leave it at its default so a missing directory surfaces loudly in
# dev. In prod the directory is baked into the container image.
application.mount(
"/static",
StaticFiles(directory=_STATIC_DIR),
name="static",
)
# Single shared Jinja2 environment. Storing it on ``app.state`` keeps
# route modules free of an import dependency on this module (which
# would be circular once admin/auth routers are added in later
# phases). Route handlers pull it via a ``Depends(get_templates)``
# function defined next to the routes.
application.state.templates = Jinja2Templates(directory=_TEMPLATES_DIR)
# Register routers. Kept explicit (no dynamic discovery) so the set of # Register routers. Kept explicit (no dynamic discovery) so the set of
# mounted endpoints is trivially auditable. # mounted endpoints is trivially auditable.
application.include_router(health_router) application.include_router(health_router)
application.include_router(public_router)
# Single structured startup event. Do NOT include secret material. # Single structured startup event. Do NOT include secret material.
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)

45
app/models/posts.py Normal file
View File

@@ -0,0 +1,45 @@
"""Blog post domain models.
Phase 1 only needs the *list-view* projection of a post — a minimal
immutable record sufficient to render a blog card on the home page.
Phase 2 will introduce the richer persisted :class:`Post` dataclass that
mirrors the SQLite schema; :class:`PostSummary` intentionally stays as a
narrower DTO even after the DB arrives because list endpoints shouldn't
pay the cost of loading full post bodies.
The dataclass is frozen: summaries flow one-way from the service layer
into templates and must never mutate mid-request.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PostSummary:
"""Immutable summary row for the blog index.
Attributes
----------
slug:
URL-safe identifier used to build the post's canonical URL.
title:
Human-readable headline shown on the card.
published_at:
Timezone-aware UTC publish timestamp. Templates format this for
display; storing a real :class:`datetime` (rather than a
pre-formatted string) keeps locale/formatting concerns in the
view layer.
excerpt:
Short plaintext teaser. The service layer is responsible for
producing a sanitized, already-truncated excerpt so the template
can render it without additional escaping beyond Jinja's default
HTML autoescape.
"""
slug: str
title: str
published_at: datetime
excerpt: str

125
app/routes/public.py Normal file
View File

@@ -0,0 +1,125 @@
"""Public-facing HTTP routes.
Phase 1 scope:
- ``GET /`` — blog index (currently empty list from the stub service).
- ``GET /about`` — static placeholder copy.
- ``GET /contact`` — inert contact form UI + optional ``mailto:`` link.
- ``GET /shop`` — "Coming soon" card.
Every handler is thin: it resolves its dependencies, calls any service
methods it needs, and delegates rendering to a Jinja template. No HTML is
constructed in Python.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.models.posts import PostSummary
from app.services.posts import PostService, get_post_service
# Module-level router. Mounted without a prefix by ``app.main.create_app``
# so the routes below live at the site root.
router: APIRouter = APIRouter(tags=["public"])
def get_templates(request: Request) -> Jinja2Templates:
"""Return the shared :class:`Jinja2Templates` instance.
The singleton is attached to ``app.state.templates`` in
:func:`app.main.create_app`. Looking it up via ``request.app.state``
(rather than importing from ``app.main``) avoids an import cycle and
keeps the handlers test-friendly — tests can swap out the instance by
mutating ``app.state.templates`` before issuing requests.
"""
return request.app.state.templates
@router.get("/", response_class=HTMLResponse, summary="Blog index")
def home(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
posts: PostService = Depends(get_post_service),
) -> HTMLResponse:
"""Render the blog index with any published posts.
In Phase 1 the service returns an empty list, so the template shows a
friendly "no posts yet" state. Phase 2 will populate the list from
SQLite without any changes to this handler.
"""
# Query the service layer for the most recent published posts. The
# template handles the empty-list case; we do not branch here.
summaries: list[PostSummary] = posts.list_published()
return templates.TemplateResponse(
request,
"public/home.html",
{"posts": summaries, "active_nav": "home"},
)
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
def about(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
) -> HTMLResponse:
"""Render the static About page.
Copy is deliberately generic and does not reveal the farm's street
address (Morrison, TN is mentioned; the physical address is not —
see CLAUDE.md). Head Hen will replace this content via the Phase 4
admin CMS.
"""
return templates.TemplateResponse(
request,
"public/about.html",
{"active_nav": "about"},
)
@router.get("/contact", response_class=HTMLResponse, summary="Contact the farm")
def contact(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
settings: Settings = Depends(get_settings),
) -> HTMLResponse:
"""Render the inert contact page.
The form fields are marked ``disabled`` and the form has no ``method``
attribute — it is UI-only. If ``ADMIN_CONTACT_EMAIL`` is configured
the template renders a ``mailto:`` link so visitors still have a way
to reach the farm before Phase 5 wires up the real POST flow.
"""
return templates.TemplateResponse(
request,
"public/contact.html",
{
"active_nav": "contact",
# None when unset; the template hides the mailto link in that
# case. We pass the value through settings so tests can
# override it without touching environment variables.
"contact_email": settings.admin_contact_email,
},
)
@router.get("/shop", response_class=HTMLResponse, summary="Shop placeholder")
def shop(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
) -> HTMLResponse:
"""Render the "Coming soon" placeholder for the future shop.
The nav link to ``/shop`` remains enabled so visitors can preview the
offering; it is the landing page itself that signals the shop is not
yet live. Phase 7 replaces this template with the real catalog.
"""
return templates.TemplateResponse(
request,
"public/shop.html",
{"active_nav": "shop"},
)

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

View File

496
app/static/css/site.css Normal file
View File

@@ -0,0 +1,496 @@
/* -------------------------------------------------------------------------
* Chicken Babies R Us — site.css
*
* Single stylesheet for the public brochure site. Mobile-first; one
* breakpoint at 48rem (~768px) for tablet and up. Self-hosted only; no
* external font imports or third-party CSS.
*
* Table of contents
* 1. Reset
* 2. Design tokens (:root custom properties from ROADMAP palette)
* 3. Base typography + body
* 4. Layout primitives (.wrap, header, nav, main, footer)
* 5. Components (.post-card, .shop-card, .contact-form, .btn, skip-link)
* 6. Responsive (48rem breakpoint)
* ------------------------------------------------------------------------- */
/* 1. Reset ---------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
h1, h2, h3, h4, h5, h6,
p,
figure,
blockquote,
dl,
dd {
margin: 0;
}
body {
line-height: 1.5;
min-height: 100vh;
text-rendering: optimizeSpeed;
-webkit-font-smoothing: antialiased;
}
img,
picture {
max-width: 100%;
display: block;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
/* 2. Design tokens -------------------------------------------------------- */
:root {
/* Palette (authoritative values from docs/ROADMAP.md Visual Design). */
--c-sky: #A9CCE3;
--c-sky-deep: #5D8AA8;
--c-cream: #FAF3E7;
--c-wheat: #E4D4A8;
--c-ink: #2B3A42;
--c-leaf: #7FA66B;
/* Type stacks: system fonts only so we never hit a third-party CDN. */
--font-serif: Georgia, "Times New Roman", serif;
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Spacing scale (rem-based; base = 1rem = 16px). */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--radius: 0.5rem;
--max-width: 68rem;
}
/* 3. Base typography + body ---------------------------------------------- */
body {
font-family: var(--font-sans);
background-color: var(--c-cream);
color: var(--c-ink);
display: flex;
flex-direction: column;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
color: var(--c-ink);
line-height: 1.2;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
p {
margin-block: var(--space-3);
}
a {
color: var(--c-sky-deep);
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover,
a:focus-visible {
color: var(--c-ink);
}
/* Utility: visually hide but keep available to assistive tech. */
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* 4. Layout primitives ---------------------------------------------------- */
.wrap {
width: 100%;
max-width: var(--max-width);
margin-inline: auto;
padding-inline: var(--space-3);
}
/* Skip link — hidden offscreen until focused by keyboard. */
.skip-link {
position: absolute;
top: 0;
left: 0;
padding: var(--space-2) var(--space-3);
background-color: var(--c-ink);
color: var(--c-cream);
text-decoration: none;
transform: translateY(-120%);
transition: transform 0.15s ease-out;
z-index: 100;
}
.skip-link:focus {
transform: translateY(0);
}
/* Header / brand / nav ---------------------------------------------------- */
.site-header {
background-color: var(--c-sky);
border-bottom: 1px solid rgba(43, 58, 66, 0.1);
}
.site-header__wrap {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding-block: var(--space-3);
gap: var(--space-3);
}
.site-header__brand {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.site-header__logo {
height: 48px;
width: auto;
}
/* Mobile nav toggle (shown < 48rem, hidden ≥ 48rem). */
.site-nav__toggle {
appearance: none;
background: transparent;
border: 1px solid var(--c-ink);
border-radius: var(--radius);
padding: var(--space-2);
cursor: pointer;
display: inline-flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
width: 2.5rem;
height: 2.5rem;
}
.site-nav__toggle-bar {
display: block;
height: 2px;
width: 100%;
background-color: var(--c-ink);
border-radius: 2px;
}
/* Collapsed by default on narrow viewports. */
.site-nav {
flex-basis: 100%;
display: none;
}
.site-nav.is-open {
display: block;
}
.site-nav__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-block: var(--space-2);
}
.site-nav__link {
display: block;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
text-decoration: none;
color: var(--c-ink);
font-weight: 600;
}
.site-nav__link:hover,
.site-nav__link:focus-visible {
background-color: rgba(255, 255, 255, 0.35);
color: var(--c-ink);
}
.site-nav__link.is-active {
background-color: var(--c-cream);
color: var(--c-sky-deep);
}
/* Muted link for not-yet-live destinations (Shop in Phase 1). */
.site-nav__link.nav--muted {
opacity: 0.65;
}
/* Main + footer ----------------------------------------------------------- */
.site-main {
flex: 1 0 auto;
padding-block: var(--space-5);
}
/* Remove default focus ring on <main> when focused via the skip link. */
.site-main:focus {
outline: none;
}
.site-footer {
background-color: var(--c-ink);
color: var(--c-cream);
padding-block: var(--space-4);
margin-top: var(--space-6);
}
.site-footer a {
color: var(--c-sky);
}
.site-footer__tag {
margin: 0;
font-family: var(--font-serif);
}
.site-footer__legal {
margin-top: var(--space-2);
font-size: 0.875rem;
opacity: 0.8;
}
/* 5. Components ---------------------------------------------------------- */
/* Page intro block on home. */
.page-intro {
margin-bottom: var(--space-5);
}
.page-intro__title {
margin-bottom: var(--space-2);
}
.page-intro__lede {
font-size: 1.125rem;
color: var(--c-ink);
max-width: 48rem;
}
/* Generic article wrapper for About, Contact, Shop. */
.page-article {
max-width: 48rem;
}
.page-article__header {
margin-bottom: var(--space-4);
}
.page-article__title {
margin-bottom: var(--space-2);
}
/* Post list + card. */
.post-list {
display: grid;
gap: var(--space-4);
}
.post-list__empty {
padding: var(--space-4);
background-color: var(--c-wheat);
border-radius: var(--radius);
text-align: center;
font-style: italic;
}
.post-card {
background-color: #ffffff;
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
padding: var(--space-4);
box-shadow: 0 1px 2px rgba(43, 58, 66, 0.06);
}
.post-card__header {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.post-card__title {
margin: 0;
}
.post-card__title a {
color: var(--c-ink);
text-decoration: none;
}
.post-card__title a:hover,
.post-card__title a:focus-visible {
color: var(--c-sky-deep);
text-decoration: underline;
}
.post-card__date {
font-size: 0.875rem;
color: var(--c-sky-deep);
font-family: var(--font-sans);
}
.post-card__excerpt {
margin: 0;
}
/* Shop "coming soon" card. */
.shop-card {
background-color: var(--c-wheat);
border-radius: var(--radius);
padding: var(--space-4);
}
.shop-card__title {
margin-bottom: var(--space-2);
color: var(--c-ink);
}
.shop-card__body {
margin-block: var(--space-2);
}
/* Contact form (inert in Phase 1). */
.contact-mailto {
background-color: var(--c-sky);
border-radius: var(--radius);
padding: var(--space-3);
}
.contact-mailto--muted {
background-color: var(--c-wheat);
font-style: italic;
}
.contact-form {
display: grid;
gap: var(--space-3);
max-width: 32rem;
margin-top: var(--space-3);
}
.contact-form__note {
margin-top: var(--space-3);
font-style: italic;
color: var(--c-sky-deep);
}
.contact-form__field {
display: grid;
gap: var(--space-1);
}
.contact-form__field label {
font-weight: 600;
}
.contact-form__field input,
.contact-form__field textarea {
padding: var(--space-2);
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
background-color: #ffffff;
}
.contact-form__field input:disabled,
.contact-form__field textarea:disabled {
background-color: #f5f1e6;
color: #7a7a7a;
cursor: not-allowed;
}
.contact-form__actions {
margin-top: var(--space-2);
}
/* Generic button. */
.btn {
display: inline-block;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
border: 1px solid transparent;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.btn--primary {
background-color: var(--c-sky-deep);
color: var(--c-cream);
}
.btn--primary:hover,
.btn--primary:focus-visible {
background-color: var(--c-ink);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 6. Responsive — tablet & up ------------------------------------------- */
@media (min-width: 48rem) {
h1 { font-size: 2.5rem; }
h2 { font-size: 1.75rem; }
.site-header__wrap {
flex-wrap: nowrap;
}
/* Hide the mobile toggle; show the nav inline. */
.site-nav__toggle {
display: none;
}
.site-nav {
display: block;
flex-basis: auto;
}
.site-nav__list {
flex-direction: row;
gap: var(--space-3);
padding-block: 0;
}
.post-list {
gap: var(--space-5);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
app/static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
app/static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
app/static/img/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

View File

@@ -0,0 +1,42 @@
{#
About page — static placeholder copy. Head Hen will replace this via
the Phase 4 admin CMS, so the prose below is deliberately generic and
free of lorem ipsum. Per CLAUDE.md, the physical address is not shown
anywhere on the site — only the town name.
#}
{% extends "public/base.html" %}
{% block title %}About &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}About Chicken Babies R Us &mdash; a small family farm in Morrison, Tennessee raising chickens, ducks, and geese.{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">About the farm</h1>
</header>
<p>
Chicken Babies R Us is a small family farm tucked into the rolling
hills of Morrison, Tennessee. What started as a handful of chicks
in a backyard brooder has grown into a flock of chickens, ducks, and
geese that keep us busy (and entertained) year round.
</p>
<p>
The operation is run by Head Hen &mdash; the chief wrangler, egg
gatherer, waterfowl-whisperer, and unofficial chicken photographer.
She handles the day-to-day care of the birds and does most of the
writing you'll find on this site. Expect updates on hatching plans,
new arrivals, the occasional coop mishap, and whatever the geese
decided to get into this week.
</p>
<p>
We're a hobby farm at heart, not a commercial one, which means we
can take the time to know our birds and raise them the way we think
they ought to be raised. If you're curious about what we've got
going on &mdash; or just want to say hello &mdash; pop over to the
contact page.
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,121 @@
{#
Base layout for every public page.
Child templates override the following blocks:
- title : the contents of <title>
- meta_description : contents of <meta name="description">
- content : the page body inside <main>
Design notes:
- Semantic landmarks (<header>, <nav>, <main>, <footer>) for a11y.
- Skip-link is the first focusable element so keyboard users can jump
past the header.
- aria-current="page" is applied to the active nav link by comparing
the `active_nav` context variable the route passed us.
- The mobile nav toggle uses addEventListener only — no inline event
handlers — so we stay CSP-nonce-compatible when Phase 6 adds the
strict CSP middleware.
#}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Chicken Babies R Us{% endblock %}</title>
<meta name="description" content="{% block meta_description %}Small-farm fresh eggs and happy birds, raised in Morrison, Tennessee.{% endblock %}">
{# Self-hosted favicon + apple touch icon — no third-party CDNs. #}
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
</head>
<body>
{# Skip link: hidden until focused. First focusable element on the page. #}
<a class="skip-link" href="#main-content">Skip to main content</a>
<header class="site-header">
<div class="wrap site-header__wrap">
<a class="site-header__brand" href="/" aria-label="Chicken Babies R Us home">
{# WebP with PNG fallback — generated by scripts/generate_static_assets.py. #}
<picture>
<source srcset="{{ url_for('static', path='img/logo.webp') }}" type="image/webp">
<img src="{{ url_for('static', path='img/logo.png') }}"
alt="Chicken Babies R Us"
height="48"
class="site-header__logo">
</picture>
</a>
{# The mobile toggle button — script below attaches a click handler
that flips aria-expanded and toggles .is-open on the nav. #}
<button type="button"
class="site-nav__toggle"
id="nav-toggle"
aria-controls="primary-nav"
aria-expanded="false">
<span class="visually-hidden">Toggle navigation</span>
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
</button>
<nav class="site-nav" id="primary-nav" aria-label="Primary">
<ul class="site-nav__list">
<li class="site-nav__item">
<a href="/"
class="site-nav__link{% if active_nav == 'home' %} is-active{% endif %}"
{% if active_nav == 'home' %}aria-current="page"{% endif %}>Home</a>
</li>
<li class="site-nav__item">
<a href="/about"
class="site-nav__link{% if active_nav == 'about' %} is-active{% endif %}"
{% if active_nav == 'about' %}aria-current="page"{% endif %}>About</a>
</li>
<li class="site-nav__item">
<a href="/contact"
class="site-nav__link{% if active_nav == 'contact' %} is-active{% endif %}"
{% if active_nav == 'contact' %}aria-current="page"{% endif %}>Contact</a>
</li>
<li class="site-nav__item">
<a href="/shop"
class="site-nav__link nav--muted{% if active_nav == 'shop' %} is-active{% endif %}"
{% if active_nav == 'shop' %}aria-current="page"{% endif %}>Shop</a>
</li>
</ul>
</nav>
</div>
</header>
<main id="main-content" class="site-main" tabindex="-1">
<div class="wrap">
{% block content %}{% endblock %}
</div>
</main>
<footer class="site-footer">
<div class="wrap site-footer__wrap">
<p class="site-footer__tag">
Chicken Babies R Us &middot; Morrison, Tennessee
</p>
<p class="site-footer__legal">
&copy; {{ now_year or 2026 }} Chicken Babies R Us. All rights reserved.
</p>
</div>
</footer>
{# Mobile nav toggle. Tiny and CSP-friendly: no inline handlers, no JS
framework. Phase 6's CSP will be compatible with moving this into an
external file + nonce if we grow; for now the inline block stays. #}
<script>
(function () {
"use strict";
var toggle = document.getElementById("nav-toggle");
var nav = document.getElementById("primary-nav");
if (!toggle || !nav) { return; }
toggle.addEventListener("click", function () {
var expanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
nav.classList.toggle("is-open");
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,85 @@
{#
Contact page — Phase 1 version.
The form is deliberately inert: no `method`, no `action`, all inputs
and the submit button carry the `disabled` attribute. A muted note
explains the form is coming soon; if `ADMIN_CONTACT_EMAIL` is set in
the environment we render a `mailto:` link above the form so visitors
still have a way to reach the farm.
Phase 5 replaces this template with a working POST handler, hCaptcha,
honeypot, and rate limiting.
Context:
- contact_email : str | None (from settings.admin_contact_email)
- active_nav : "contact"
#}
{% extends "public/base.html" %}
{% block title %}Contact &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}Get in touch with Chicken Babies R Us.{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Get in touch</h1>
</header>
<p>
We'd love to hear from you &mdash; questions about the birds,
availability, or just to say hi.
</p>
{% if contact_email %}
<p class="contact-mailto">
The easiest way to reach us right now is email:
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
</p>
{% else %}
<p class="contact-mailto contact-mailto--muted">
A direct email address will be posted here soon.
</p>
{% endif %}
<p class="contact-form__note" role="note">
Secure contact form coming soon.
</p>
{# action="" and no method = form cannot submit. Every input is
disabled so screen readers and the keyboard both respect the
"not-yet-available" state. #}
<form class="contact-form" action="" aria-describedby="contact-form-note" novalidate>
<div class="contact-form__field">
<label for="contact-name">Name</label>
<input type="text"
id="contact-name"
name="name"
autocomplete="name"
disabled>
</div>
<div class="contact-form__field">
<label for="contact-email">Email</label>
<input type="email"
id="contact-email"
name="email"
autocomplete="email"
disabled>
</div>
<div class="contact-form__field">
<label for="contact-message">Message</label>
<textarea id="contact-message"
name="message"
rows="6"
disabled></textarea>
</div>
<div class="contact-form__actions">
<button type="submit" class="btn btn--primary" disabled>
Send message
</button>
</div>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{#
Home page / blog index.
Receives:
- posts : list[PostSummary] (empty in Phase 1)
- active_nav : str "home"
#}
{% extends "public/base.html" %}
{% block title %}Chicken Babies R Us &mdash; Home{% endblock %}
{% block meta_description %}Updates from Chicken Babies R Us &mdash; a small family farm in Morrison, Tennessee.{% endblock %}
{% block content %}
<section class="page-intro">
<h1 class="page-intro__title">Welcome to Chicken Babies R Us</h1>
<p class="page-intro__lede">
A tiny family farm in Morrison, Tennessee. Follow along for updates
on our flock, hatching plans, and whatever Head Hen is up to this week.
</p>
</section>
<section class="post-list" aria-label="Latest posts">
{% if posts %}
{% for post in posts %}
{% include "public/partials/_post_card.html" %}
{% endfor %}
{% else %}
{# Empty-state copy. Phase 2 seeds a welcome post so this state only
ever shows up in unseeded dev databases and tests. #}
<div class="post-list__empty">
<p>No posts yet &mdash; check back soon!</p>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{#
Single blog card. Rendered once per PostSummary in the home-page loop.
Expects the loop variable `post` in scope with:
- post.slug (str)
- post.title (str)
- post.published_at (datetime)
- post.excerpt (str)
The post detail page does not exist yet (Phase 2 adds it), but we link
to /posts/<slug> anyway so the card markup is final. Phase 2 will
register the route; until then the link 404s, which is acceptable
because the post list itself is empty in Phase 1.
#}
<article class="post-card">
<header class="post-card__header">
<h2 class="post-card__title">
<a href="/posts/{{ post.slug }}">{{ post.title }}</a>
</h2>
<time class="post-card__date"
datetime="{{ post.published_at.isoformat() }}">
{{ post.published_at.strftime("%B %-d, %Y") }}
</time>
</header>
<p class="post-card__excerpt">{{ post.excerpt }}</p>
</article>

View File

@@ -0,0 +1,32 @@
{#
Shop placeholder. Phase 7 replaces this with a real Stripe-backed
catalog. For now the page itself is the "disabled" UI; the nav link
uses the `nav--muted` class to hint that it isn't fully live.
#}
{% extends "public/base.html" %}
{% block title %}Shop &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}Our farm shop is coming soon &mdash; eggs, chicks, and waterfowl.{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Shop</h1>
</header>
<section class="shop-card" aria-label="Shop status">
<h2 class="shop-card__title">Coming soon</h2>
<p class="shop-card__body">
We're getting the farm shop ready. Soon you'll be able to order
eating eggs, fertile hatching eggs, day-old chicks, and a small
selection of waterfowl (ducks and geese) when available. Pickup
will be local to Morrison; we'll share details here when the
shop goes live.
</p>
<p class="shop-card__body">
In the meantime, if you're looking for something specific, the
contact page is the best way to reach us.
</p>
</section>
</article>
{% endblock %}

90
docs/MANUAL_TESTING.md Normal file
View File

@@ -0,0 +1,90 @@
# Manual Testing Checklist
Living document. Each phase appends its own section — do not delete older
sections when the code behind them changes; mark items as superseded
instead so the audit trail stays intact.
Run the site locally before walking through the list:
```bash
source venv/bin/activate
uvicorn app.main:app --reload
```
Then open `http://127.0.0.1:8000/` in a real browser (not just curl).
---
## Phase 1 — Public Site Skeleton
### Home (`/`)
- [ ] Page returns 200 and renders without console errors.
- [ ] Header shows the Chicken Babies R Us logo at ~48px tall.
- [ ] `<img>` `alt` attribute reads **Chicken Babies R Us**.
- [ ] Nav items appear in order: Home · About · Contact · Shop.
- [ ] "Home" is visibly the active nav link and carries `aria-current="page"`.
- [ ] "Shop" nav link is visually muted (lower contrast) but still clickable.
- [ ] Page intro ("Welcome to Chicken Babies R Us") is present.
- [ ] With no posts in the DB, the empty-state reads **"No posts yet — check back soon!"**.
- [ ] Footer shows "Chicken Babies R Us · Morrison, Tennessee".
- [ ] No street address is visible anywhere on the page (CLAUDE.md constraint).
### About (`/about`)
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"About the farm"**.
- [ ] Copy mentions Morrison, Tennessee by name.
- [ ] Copy name-checks Head Hen.
- [ ] No street address appears anywhere.
- [ ] Nav marks "About" as active (`aria-current="page"`).
### Contact (`/contact`)
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"Get in touch"**.
- [ ] When `ADMIN_CONTACT_EMAIL` is set, a `mailto:` link renders above the form.
- [ ] When `ADMIN_CONTACT_EMAIL` is unset, the muted placeholder sentence appears and no `mailto:` link renders.
- [ ] The note **"Secure contact form coming soon"** is visible.
- [ ] Form fields (name, email, message) are visually disabled and cannot be typed into.
- [ ] "Send message" button is visually disabled.
- [ ] Form has no `method="POST"` attribute (view source).
- [ ] Nav marks "Contact" as active.
### Shop (`/shop`)
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"Shop"**.
- [ ] "Coming soon" card is visible with mention of eggs, chicks, and waterfowl.
- [ ] Nav marks "Shop" as active.
### Responsive
Use the browser devtools responsive toolbar.
- [ ] **360 × 800 (mobile):** nav collapses behind a hamburger toggle; toggle opens/closes on click; logo remains legible; no horizontal scroll.
- [ ] **768 × 1024 (tablet):** nav appears inline; layout uses full container width; no horizontal scroll.
- [ ] **1280 × 800 (desktop):** content capped at `--max-width` (68rem ≈ 1088px); generous whitespace either side.
### Accessibility
- [ ] Tab-key order from top of page: skip link → logo → nav links → main content.
- [ ] Pressing **Tab** from a cold page load reveals the skip link in the top-left corner.
- [ ] Activating the skip link jumps focus into `<main>`.
- [ ] Logo has a non-empty `alt` attribute ("Chicken Babies R Us").
- [ ] Navigating with a screen reader announces each landmark (`header`, `nav`, `main`, `footer`).
- [ ] Spot-check color contrast of `--c-ink` (#2B3A42) on `--c-cream` (#FAF3E7) — should be comfortably above WCAG AA for body text.
### Assets
- [ ] `/static/img/logo.png` loads and is roughly 256px tall.
- [ ] `/static/img/logo.webp` loads with content-type `image/webp`.
- [ ] `/static/img/favicon.ico` is requested by the browser and returns 200.
- [ ] `/static/img/apple-touch-icon.png` is 180×180 and has a cream (#FAF3E7) background.
### Ops smoke
- [ ] `pytest -q` passes locally.
- [ ] `python -c "from app.main import app"` exits cleanly.
- [ ] `python scripts/generate_static_assets.py` regenerates the four asset files without error.
- [ ] `docker compose config` still parses cleanly.

View File

@@ -37,13 +37,44 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 1 passed ✓ · `curl /healthz` returned both the default `"unknown"` payload and the real commit SHA when `GIT_COMMIT_SHA=$(git rev-parse HEAD)` was set ✓ · `docker compose config` exit 0 ✓. - **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 1 passed ✓ · `curl /healthz` returned both the default `"unknown"` payload and the real commit SHA when `GIT_COMMIT_SHA=$(git rev-parse HEAD)` was set ✓ · `docker compose config` exit 0 ✓.
- **Branch:** built on `chore/phase-0-foundation` off `dev`; merged `--no-ff` into `dev` on completion. Not pushed. - **Branch:** built on `chore/phase-0-foundation` off `dev`; merged `--no-ff` into `dev` on completion. Not pushed.
## Phase 1 — Public Site Skeleton ## Phase 1 — Public Site Skeleton
- Base Jinja layout: header with logo, nav (Home · About · Contact · Shop (disabled)), footer. **Completed:** 2026-04-21
- Mobile-first responsive CSS, no JS framework. CSS custom properties from the palette below.
- Routes: `/`, `/about`, `/contact`, `/shop` (shop shows "Coming soon" card, no form). **Summary:** Shipped the public brochure site: base Jinja layout with logo + nav + footer, mobile-first single-file CSS using the ROADMAP palette, and four public routes (`/`, `/about`, `/contact`, `/shop`). Blog index renders via a service stub returning `[]`; Phase 2 swaps the body for SQLite without touching the route.
- `/` renders the blog index from DB (empty list is acceptable this phase).
- Manual test checklist → `docs/MANUAL_TESTING.md`. **Key files:**
- `app/models/posts.py``PostSummary` `@dataclass(frozen=True)` with `slug/title/published_at/excerpt` — list-view projection used by the homepage; richer `Post` arrives in Phase 2 alongside.
- `app/services/posts.py``PostService.list_published(limit=20) -> list[PostSummary]` stub returning `[]`; `get_post_service()` DI helper (Phase 2 keeps the signature, swaps the body).
- `app/routes/public.py``APIRouter` with `GET /`, `/about`, `/contact`, `/shop`; pulls templates off `app.state.templates` via `get_templates()` DI helper.
- `app/templates/public/base.html` — layout: skip link, `<header>`/`<nav>`/`<main>`/`<footer>`, `aria-current` on active nav item, `<picture>` logo (WebP + PNG fallback), favicon/apple-touch-icon links, mobile nav toggle via plain `addEventListener` script.
- `app/templates/public/home.html` — blog index; loops `_post_card.html` or renders "No posts yet — check back soon!" on empty list.
- `app/templates/public/about.html` — static placeholder copy (Head Hen rewrites via Phase 4 admin).
- `app/templates/public/contact.html` — inert form: all inputs `disabled`, no `method="POST"`, `action=""`, shows `mailto:` link only if `settings.admin_contact_email` is truthy.
- `app/templates/public/shop.html` — "Coming soon" card teasing eggs / chicks / waterfowl.
- `app/templates/public/partials/_post_card.html` — single post-card partial.
- `app/static/css/site.css` — single stylesheet: reset, `:root` palette tokens (`--c-sky`, `--c-sky-deep`, `--c-cream`, `--c-wheat`, `--c-ink`, `--c-leaf`) + spacing/radius scale, system font stacks, components, one 48rem breakpoint.
- `app/static/img/logo.png` (573×256 RGBA), `logo.webp` (q=82, method=6), `favicon.ico` (16/32/48), `apple-touch-icon.png` (180×180 on `#FAF3E7`).
- `scripts/generate_static_assets.py` — Pillow CLI `StaticAssetBuilder` that regenerates the four image assets from `Logo/chicken babies r us.png`; committed for reproducibility.
- `docs/MANUAL_TESTING.md` — per-route + responsive (360/768/1280px) + a11y + static-assets checklist.
- `tests/test_public_routes.py` — 7 tests (4 parametrized route smokes + empty-state copy + logo path + `aria-current`).
- `app/main.py` — modified: `StaticFiles` mount at `/static`, `Jinja2Templates` instantiated once onto `app.state.templates`, `public_router` included; `create_app()` stays idempotent.
**Endpoints created:**
- `GET /` — blog index (empty-state message until Phase 2 seeds content).
- `GET /about` — static About page.
- `GET /contact` — inert form + optional `mailto:` from `ADMIN_CONTACT_EMAIL`. Phase 5 replaces with a working POST.
- `GET /shop` — "Coming soon" card.
- `Mount /static``StaticFiles` serving `app/static/{css,img}` (and `fonts/` later if ever needed).
**Key details:**
- **Stable seam for Phase 2:** `PostService.list_published()` + `PostSummary` names are fixed contracts. Phase 2 only changes the method body to hit SQLite.
- **No DB, no CSRF, no CSP, no auth, no contact POST.** Scope held strictly to the roadmap's Phase 1 bullets.
- **Templates live under `app/templates/public/`** to reserve `app/templates/admin/` and `app/templates/emails/` for Phases 35.
- **Logo delivery:** `<picture><source type="image/webp"><img alt="Chicken Babies R Us" height="48"></picture>` — modern browsers pull the WebP, older ones fall back to PNG.
- **Address still not rendered.** Only city + state ("Morrison, Tennessee") appear per CLAUDE.md's "address is intentionally not displayed" wording.
- **No new packages.** Pillow / Jinja2 / Starlette StaticFiles were already in `requirements.txt` from Phase 0.
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 8 passed ✓ · uvicorn smoke: `/`, `/about`, `/contact`, `/shop`, `/healthz`, `/static/css/site.css`, `/static/img/logo.webp` all 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page has `disabled` inputs and no `method` attribute ✓ · `docker compose config` exit 0 ✓.
## Phase 2 — Content Model + Cache ## Phase 2 — Content Model + Cache

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

103
tests/test_public_routes.py Normal file
View File

@@ -0,0 +1,103 @@
"""Smoke tests for the public-site skeleton routes.
These tests focus on contract rather than styling:
- every public route returns 200 with an HTML content-type
- each page contains a page-specific substring (proves the template
actually rendered, not just that the route exists)
- the homepage renders the empty-state copy when PostService returns []
- the shared layout emits the logo image path so nav/logo aren't broken
by a future refactor
No mocks of the DB (there is no DB in Phase 1). The PostService stub
already returns an empty list, which is exactly what we want to assert
against.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="module")
def client() -> TestClient:
"""Return a module-scoped FastAPI TestClient.
TestClient uses the module-level `app` built by `create_app()` at
import time, i.e. the exact same app uvicorn runs in production.
"""
return TestClient(app)
@pytest.mark.parametrize(
"path,expected_substring",
[
("/", "Chicken Babies"),
("/about", "About the farm"),
("/contact", "Get in touch"),
("/shop", "Coming soon"),
],
)
def test_public_route_renders_html(
client: TestClient,
path: str,
expected_substring: str,
) -> None:
"""Every public page returns 200 HTML containing a page-specific string.
The substring is intentionally a headline the template owns so the
test fails loudly if the wrong template is accidentally wired up.
"""
response = client.get(path)
assert response.status_code == 200, (
f"{path} returned {response.status_code}: {response.text[:200]}"
)
content_type = response.headers.get("content-type", "")
assert content_type.startswith("text/html"), (
f"{path} returned unexpected content-type: {content_type!r}"
)
assert expected_substring in response.text, (
f"{path} body missing expected substring {expected_substring!r}"
)
def test_home_shows_empty_state_when_no_posts(client: TestClient) -> None:
"""With the Phase 1 stub service, the home page shows 'No posts yet'.
This is the canonical empty-state marker; Phase 2 seeds a welcome
post so this test will need to be updated when the DB lands.
"""
response = client.get("/")
assert response.status_code == 200
assert "No posts yet" in response.text
def test_layout_includes_logo_image(client: TestClient) -> None:
"""Shared layout references the generated logo asset paths.
We check for the stem (``/static/img/logo.``) rather than a specific
extension so both the <source srcset="...webp"> and <img src="...png">
markup are covered by a single assertion.
"""
response = client.get("/")
assert response.status_code == 200
assert "/static/img/logo." in response.text
def test_nav_marks_active_page(client: TestClient) -> None:
"""The About page renders ``aria-current=\"page\"`` on its nav link.
Exercises the shared layout's active-nav logic end-to-end without
inspecting internals.
"""
response = client.get("/about")
assert response.status_code == 200
# The pair should appear exactly once per active link; we only need
# to prove it's present at all.
assert 'aria-current="page"' in response.text