Merge branch 'chore/phase-1-public-skeleton' into dev: Phase 1 complete
This commit is contained in:
37
app/main.py
37
app/main.py
@@ -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
45
app/models/posts.py
Normal 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
125
app/routes/public.py
Normal 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
61
app/services/posts.py
Normal 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
|
||||||
496
app/static/css/site.css
Normal file
496
app/static/css/site.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/static/img/apple-touch-icon.png
Normal file
BIN
app/static/img/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
app/static/img/favicon.ico
Normal file
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
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
BIN
app/static/img/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
42
app/templates/public/about.html
Normal file
42
app/templates/public/about.html
Normal 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 — Chicken Babies R Us{% endblock %}
|
||||||
|
{% block meta_description %}About Chicken Babies R Us — 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 — 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 — or just want to say hello — pop over to the
|
||||||
|
contact page.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
121
app/templates/public/base.html
Normal file
121
app/templates/public/base.html
Normal 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 · Morrison, Tennessee
|
||||||
|
</p>
|
||||||
|
<p class="site-footer__legal">
|
||||||
|
© {{ 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>
|
||||||
85
app/templates/public/contact.html
Normal file
85
app/templates/public/contact.html
Normal 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 — 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 — 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 %}
|
||||||
35
app/templates/public/home.html
Normal file
35
app/templates/public/home.html
Normal 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 — Home{% endblock %}
|
||||||
|
{% block meta_description %}Updates from Chicken Babies R Us — 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 — check back soon!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
26
app/templates/public/partials/_post_card.html
Normal file
26
app/templates/public/partials/_post_card.html
Normal 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>
|
||||||
32
app/templates/public/shop.html
Normal file
32
app/templates/public/shop.html
Normal 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 — Chicken Babies R Us{% endblock %}
|
||||||
|
{% block meta_description %}Our farm shop is coming soon — 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
90
docs/MANUAL_TESTING.md
Normal 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.
|
||||||
@@ -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 3–5.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
|||||||
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())
|
||||||
103
tests/test_public_routes.py
Normal file
103
tests/test_public_routes.py
Normal 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
|
||||||
Reference in New Issue
Block a user