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 pathlib import Path
import structlog
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app import __version__
from app.config import get_settings
from app.logging_config import configure_logging
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:
@@ -24,7 +37,11 @@ def create_app() -> FastAPI:
- Load validated configuration via :func:`get_settings`.
- Initialize structured logging *before* any logger is used.
- 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.
"""
# Parse + validate configuration first so a bad environment fails fast
@@ -44,9 +61,27 @@ def create_app() -> FastAPI:
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
# mounted endpoints is trivially auditable.
application.include_router(health_router)
application.include_router(public_router)
# Single structured startup event. Do NOT include secret material.
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 %}