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:
37
app/main.py
37
app/main.py
@@ -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
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 %}
|
||||
Reference in New Issue
Block a user