Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
# Branding: unified header & footer

**Date:** 2026-06-25
**Status:** approved-to-implement (proceeding under an active `/goal` directive)

## Goal

Improve the app's branding so it is consistent and present in **both the header
and the footer** of every shell — authenticated app, admin panel, public
landing, and auth-card screens.

## Current state

The codebase already has a server-driven branding system:

- A `branding` module emits a `branding` Inertia shared prop:
`{ appName, primaryColor, logoUrl, faviconUrl }` (default `appName` =
`"SimpleModule"`).
- `BrandingHead` applies the favicon + primary colour on every page.
- `BrandingMark` renders the logo/initial badge + wordmark and is used in the
`SidebarLayout` header (desktop sidebar + mobile top bar).

Gaps:

1. **No footer in the authenticated/admin app shell** (`SidebarLayout`). Only
the public landing page has a footer.
2. The public footer and the `AuthCardShell` brand lockup **hardcode** brand
text (`simple_module_python · MIT`, `simple_module` / `python`) instead of
reading the `branding` prop — wrong for white-labelled deploys.
3. Brand link URLs (repo / docs / changelog) are duplicated inline.

## Design

Refined-minimal, matching the existing aesthetic (emerald `oklch` primary, Sora
display font, JetBrains Mono technical caption, subtle `border-border`, gradient
brand badge). No new server settings, no migrations — purely a frontend
consolidation in `packages/ui`.

### 1. `lib/brand.ts` — single source for static brand metadata

Framework-level constants that are not part of the white-labellable
`branding` prop: `BRAND_REPO_URL`, `BRAND_LICENSE` (`MIT`), `BRAND_TECH`
(`python`), and `BRAND_FOOTER_LINKS` (Docs / Changelog / GitHub).

### 2. `BrandingMark` gains an optional stacked caption

Add optional `caption` / `captionClassName` props. When `caption` is set the
wordmark + caption stack in a column (badge stays to the left). Backward
compatible — existing callers (sidebar header, mobile bar) pass no caption and
render exactly as before. This makes `BrandingMark` the single brand-lockup
primitive used by the header, footer, and auth shell.

### 3. `BrandingFooter` — one reusable footer

New presentational component: brand lockup (`BrandingMark`, small, light label,
caption `© <year> · MIT`) on the left; `BRAND_FOOTER_LINKS` on the right.
`variant` prop: `public` (centred `max-w-6xl`) vs `app` (full content width).
Pure/props-driven so it unit-tests without Inertia. Year computed at runtime
(client-only render, no SSR — safe).

### 4. Wire it in

- `SidebarLayout`: make `<main>` a flex column (`flex-1` content wrapper +
sticky-bottom footer) and render `<BrandingFooter variant="app" />` driven by
the already-derived `appName` / `logoUrl`. Both `AuthenticatedLayout` and
`AdminLayout` inherit it.
- `PublicLayout`: replace the bespoke footer with `<BrandingFooter
variant="public" />`; point the nav's external links at `BRAND_REPO_URL`.
- `AuthCardShell`: replace the hardcoded `simple_module` / `python` lockup with
`BrandingMark` driven by the `branding` prop (`caption` = `BRAND_TECH`).

### 5. Tests

Follow the repo's co-located `*.test.tsx` pattern: new `BrandingFooter.test.tsx`
(app name, links, year/licence caption) and an added caption case in
`BrandingMark.test.tsx`.

## Out of scope / assumptions

- No new server-side branding settings (tagline, version, custom footer links).
Footer links remain framework constants.
- Footer link labels stay un-localised, matching the existing public footer
(they are largely proper nouns: Docs / Changelog / GitHub).
- 300-line cap respected; all new files are small and presentational.
30 changes: 30 additions & 0 deletions framework/hosting/simple_module_hosting/_inertia_setup.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

from fastapi import FastAPI
from inertia import InertiaConfig, inertia_dependency_factory
from starlette.requests import Request

from simple_module_hosting.settings import Settings

Expand All@@ -19,6 +20,33 @@
_ROOT_TEMPLATE_FILENAME = "index.html"
_ENTRYPOINT_FILENAME = "main.tsx"
_ROOT_DIRECTORY = "."

# Fallback app name when the (optional) branding module isn't installed. Mirrors
# branding's own default so the unbranded title is identical everywhere.
_DEFAULT_APP_NAME = "SimpleModule"


def branding_head(request: Request) -> dict:
"""Branding metadata for the root template's ``<head>``.

Reads the optional branding module's settings off ``app.state`` by name
(duck-typed, never imported) so the static ``<title>`` and ``theme-color``
are already branded *before* React hydrates. Degrades to the framework
default when branding isn't installed. Only plain settings strings are
surfaced here — the favicon is applied client-side by ``BrandingHead`` via
Inertia's ``<Head>``, keeping file_storage's download-route shape out of
framework code.
"""
services = getattr(request.app.state, "branding", None)
settings = getattr(services, "settings", None)
if settings is None:
return {"app_name": _DEFAULT_APP_NAME, "theme_color": None}
return {
"app_name": getattr(settings, "app_name", "") or _DEFAULT_APP_NAME,
"theme_color": getattr(settings, "primary_color", "") or None,
}


# Built assets are served from the "/static" mount under "dist/", so production
# asset URLs are prefixed with "static/dist".
_ASSETS_PREFIX = "static/dist"
Expand DownExpand Up@@ -120,6 +148,8 @@ def setup_inertia(
return None

templates = Jinja2Templates(directory=directories)
# Expose branding metadata to the root template (pre-hydration head tags).
templates.env.globals["branding_head"] = branding_head

# fastapi-inertia only switches to the asset manifest when environment
# equals the literal string "production". Anything else (staging, qa,
Expand Down
32 changes: 32 additions & 0 deletions framework/hosting/tests/test_branding_head.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
"""Tests for the root-template branding head metadata helper."""

from __future__ import annotations

from types import SimpleNamespace

from simple_module_hosting._inertia_setup import branding_head


def _request(branding: object | None) -> SimpleNamespace:
state = SimpleNamespace()
if branding is not None:
state.branding = branding
return SimpleNamespace(app=SimpleNamespace(state=state))


def test_defaults_when_branding_not_installed() -> None:
meta = branding_head(_request(None))
assert meta == {"app_name": "SimpleModule", "theme_color": None}


def test_reads_app_name_and_theme_color() -> None:
settings = SimpleNamespace(app_name="Acme", primary_color="#1a7dd1")
meta = branding_head(_request(SimpleNamespace(settings=settings)))
assert meta["app_name"] == "Acme"
assert meta["theme_color"] == "#1a7dd1"


def test_blank_values_fall_back() -> None:
settings = SimpleNamespace(app_name="", primary_color="")
meta = branding_head(_request(SimpleNamespace(settings=settings)))
assert meta == {"app_name": "SimpleModule", "theme_color": None}
16 changes: 10 additions & 6 deletions host/client_app/app.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
import { createInertiaApp, router } from '@inertiajs/react';
import { ErrorBoundary } from '@simple-module-py/ui/components/ErrorBoundary';
import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title';
import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n';
import { resolvePage } from './pages';

// NOTE: the browser-tab title is not branded with the configured app name.
// The in-app branding (sidebar name/logo, favicon, primary colour) is applied
// via the `branding` shared prop + BrandingHead; wiring the configured name
// into the document <title> is a deferred follow-up (Inertia's title callback
// can't read live page props, and this app's title updates are page-driven).
// The configured app name is held in the app-title module (Inertia's title
// callback runs outside React and can't read live page props). It's seeded
// below from the initial page's `branding` shared prop, kept fresh by
// BrandingHead, and server-rendered into the root template's static <title> so
// the pre-hydration tab is already branded.
createInertiaApp({
title: (title) => (title ? `${title} — SimpleModule` : 'SimpleModule'),
title: (title) => formatTitle(title),
resolve: async (name) => {
const page = await resolvePage(name);
return page;
},
setup({ el, App, props }) {
const branding = (props.initialPage.props as { branding?: { appName?: string | null } })
.branding;
setTitleAppName(branding?.appName);
bootI18nFromInitialPage(props.initialPage.props);

function Root() {
Expand Down
4 changes: 3 additions & 1 deletion host/templates/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SimpleModule</title>
{% set _brand = branding_head(request) %}
<title>{{ _brand.app_name }}</title>
{% if _brand.theme_color %}<meta name="theme-color" content="{{ _brand.theme_color }}" />{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Sora:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
Expand Down
18 changes: 18 additions & 0 deletions modules/branding/branding/constants.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,3 +45,21 @@
# (branding depends on FileStorage) so it tracks any route change. The
# ``{file_id}`` placeholder is filled per stored-file id.
FILE_DOWNLOAD_URL: Final = ROUTE_PREFIX_API + PATH_FILE_DOWNLOAD


def clean_app_name(value: str) -> str:
"""Normalise + validate an app name (shared by the settings + update DTO).

The name is surfaced in HTML titles and—critically—email ``Subject``
headers, so control characters (notably CR/LF) must be rejected: an
embedded newline would otherwise pass a bare ``strip()`` and then raise
when set as a header, breaking every transactional email.
"""
cleaned = value.strip()
if not cleaned:
raise ValueError("app_name must not be blank")
if len(cleaned) > MAX_APP_NAME_LEN:
raise ValueError(f"app_name must be at most {MAX_APP_NAME_LEN} characters")
if any(ord(ch) < 0x20 for ch in cleaned):
raise ValueError("app_name must not contain control characters")
return cleaned
12 changes: 5 additions & 7 deletions modules/branding/branding/contracts/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
from pydantic import field_validator
from sqlmodel import Field, SQLModel

from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN
from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN, clean_app_name


class BrandingOut(SQLModel):
Expand All@@ -26,14 +26,12 @@ class BrandingUpdate(SQLModel):
@field_validator("app_name")
@classmethod
def _non_empty_name(cls, value: str | None) -> str | None:
# Reject blank/whitespace here so it surfaces as a 422 rather than a 500
# when BrandingSettings (which strips + requires non-empty) re-validates.
# Validate here so bad input surfaces as a 422 rather than a 500 when
# BrandingSettings re-validates (blank, too long, or control chars —
# the last would otherwise break email Subject headers downstream).
if value is None:
return None
cleaned = value.strip()
if not cleaned:
raise ValueError("app_name must not be blank")
return cleaned
return clean_app_name(value)

@field_validator("primary_color")
@classmethod
Expand Down
9 changes: 2 additions & 7 deletions modules/branding/branding/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN
from branding.constants import HEX_COLOR_RE, clean_app_name

DEFAULT_APP_NAME = "SimpleModule"

Expand All@@ -32,12 +32,7 @@ class BrandingSettings(BaseSettings):
@field_validator("app_name")
@classmethod
def _non_empty_name(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("app_name must not be blank")
if len(cleaned) > MAX_APP_NAME_LEN:
raise ValueError(f"app_name must be at most {MAX_APP_NAME_LEN} characters")
return cleaned
return clean_app_name(value)

@field_validator("primary_color")
@classmethod
Expand Down
35 changes: 35 additions & 0 deletions modules/branding/tests/test_branding.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,14 @@ def test_settings_app_name_trimmed_and_required() -> None:
BrandingSettings(app_name="x" * 61)


def test_settings_app_name_rejects_control_chars() -> None:
# A CR/LF in the name would break email Subject headers downstream — it must
# be rejected at the source rather than passing a bare strip().
for bad in ("Acme\nCorp", "Acme\rCorp", "Acme\tInc"):
with pytest.raises(ValueError):
BrandingSettings(app_name=bad)


def test_settings_primary_color_validation() -> None:
assert BrandingSettings(primary_color="#1A7DD1").primary_color == "#1a7dd1"
assert BrandingSettings(primary_color="").primary_color == ""
Expand DownExpand Up@@ -108,6 +116,24 @@ async def test_update_persists_and_hot_swaps(app, authenticated_client: httpx.As
assert again.json()["app_name"] == "Acme Corp"


async def test_root_template_reflects_branding(
app, authenticated_client: httpx.AsyncClient
) -> None:
"""The pre-hydration HTML shell carries the branded title + theme-color."""
# Default name before any change.
default_page = await authenticated_client.get("/branding/", follow_redirects=False)
assert default_page.status_code == 200, default_page.text
assert "<title>SimpleModule</title>" in default_page.text

await authenticated_client.put(
"/api/branding/",
json={"app_name": "Acme Corp", "primary_color": "#1A7DD1"},
)
page = await authenticated_client.get("/branding/", follow_redirects=False)
assert "<title>Acme Corp</title>" in page.text
assert '<meta name="theme-color" content="#1a7dd1" />' in page.text


async def test_update_rejects_bad_hex(authenticated_client: httpx.AsyncClient) -> None:
resp = await authenticated_client.put("/api/branding/", json={"primary_color": "nope"})
assert resp.status_code == 422
Expand All@@ -119,6 +145,15 @@ async def test_update_rejects_blank_app_name(authenticated_client: httpx.AsyncCl
assert resp.status_code == 422


async def test_update_rejects_control_char_app_name(
authenticated_client: httpx.AsyncClient,
) -> None:
# A newline must be a clean 422 — otherwise it would later break email
# Subject headers (it now flows into invite/verify/reset subjects).
resp = await authenticated_client.put("/api/branding/", json={"app_name": "Acme\nCorp"})
assert resp.status_code == 422


async def test_logo_upload_rejects_non_image(authenticated_client: httpx.AsyncClient) -> None:
resp = await authenticated_client.post(
"/api/branding/logo",
Expand Down
50 changes: 50 additions & 0 deletions modules/users/tests/test_mailer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,3 +78,53 @@ async def test_base_url_trailing_slash_stripped(caplog):
link = caplog.records[0].link # type: ignore[attr-defined]
assert not link.startswith("http://localhost:8000//")
assert link == "http://localhost:8000/users/verify?token=tok"


@pytest.mark.anyio
async def test_console_logs_configured_app_name(caplog):
"""A supplied provider brands the console log; otherwise the default."""
from users.mailer.console import ConsoleMailer

branded = ConsoleMailer(base_url="http://localhost:8000", app_name_provider=lambda: "Acme")
default = ConsoleMailer(base_url="http://localhost:8000")

with caplog.at_level(logging.INFO, logger="users.mailer"):
await branded.send_invite("a@b.com", "tok", "Alice")
await default.send_verification("a@b.com", "tok")

assert caplog.records[0].app_name == "Acme" # type: ignore[attr-defined]
assert caplog.records[1].app_name == "SimpleModule" # type: ignore[attr-defined]


@pytest.mark.anyio
async def test_smtp_emails_carry_the_app_name(monkeypatch):
"""SMTP subjects + bodies include the live app name (and keep the link)."""
from users.mailer.smtp import SmtpMailer

captured: list[dict[str, str]] = []

async def fake_send(self, to: str, subject: str, body: str) -> None:
captured.append({"to": to, "subject": subject, "body": body})

monkeypatch.setattr(SmtpMailer, "_send", fake_send)
mailer = SmtpMailer(
host="smtp.test",
port=25,
username="",
password="",
from_address="from@test",
use_tls=False,
base_url="http://localhost:8000",
app_name_provider=lambda: "Acme",
)

await mailer.send_invite("new@x.com", "tok", "Alice")
await mailer.send_verification("v@x.com", "vtok")
await mailer.send_password_reset("r@x.com", "rtok")

invite, verify, reset = captured
assert "Acme" in invite["subject"] and "Alice" in invite["subject"]
assert "Acme" in invite["body"]
assert "http://localhost:8000/users/invite/accept?token=tok" in invite["body"]
assert "Acme" in verify["subject"] and "Acme" in verify["body"]
assert "Acme" in reset["subject"] and "Acme" in reset["body"]
Loading
Loading