diff --git a/docs/superpowers/specs/2026-06-25-branding-header-footer-design.md b/docs/superpowers/specs/2026-06-25-branding-header-footer-design.md new file mode 100644 index 00000000..d98944f2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-branding-header-footer-design.md @@ -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 `© · 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 `
` a flex column (`flex-1` content wrapper + + sticky-bottom footer) and render `` driven by + the already-derived `appName` / `logoUrl`. Both `AuthenticatedLayout` and + `AdminLayout` inherit it. +- `PublicLayout`: replace the bespoke footer with ``; 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. diff --git a/framework/hosting/simple_module_hosting/_inertia_setup.py b/framework/hosting/simple_module_hosting/_inertia_setup.py index f4f34bdc..78522c0d 100644 --- a/framework/hosting/simple_module_hosting/_inertia_setup.py +++ b/framework/hosting/simple_module_hosting/_inertia_setup.py @@ -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 @@ -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 ````. + + Reads the optional branding module's settings off ``app.state`` by name + (duck-typed, never imported) so the static ```` 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" @@ -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, diff --git a/framework/hosting/tests/test_branding_head.py b/framework/hosting/tests/test_branding_head.py new file mode 100644 index 00000000..47a8f393 --- /dev/null +++ b/framework/hosting/tests/test_branding_head.py @@ -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} diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index dbd3b24d..aed2af23 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -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() { diff --git a/host/templates/index.html b/host/templates/index.html index 5078d26e..0ba8fe17 100644 --- a/host/templates/index.html +++ b/host/templates/index.html @@ -3,7 +3,9 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>SimpleModule + {% set _brand = branding_head(request) %} + {{ _brand.app_name }} + {% if _brand.theme_color %}{% endif %} diff --git a/modules/branding/branding/constants.py b/modules/branding/branding/constants.py index 4ada9919..8ad2737e 100644 --- a/modules/branding/branding/constants.py +++ b/modules/branding/branding/constants.py @@ -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 diff --git a/modules/branding/branding/contracts/schemas.py b/modules/branding/branding/contracts/schemas.py index 0d4b1a28..b1edb767 100644 --- a/modules/branding/branding/contracts/schemas.py +++ b/modules/branding/branding/contracts/schemas.py @@ -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): @@ -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 diff --git a/modules/branding/branding/settings.py b/modules/branding/branding/settings.py index 0d85d87b..8f5e3de7 100644 --- a/modules/branding/branding/settings.py +++ b/modules/branding/branding/settings.py @@ -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" @@ -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 diff --git a/modules/branding/tests/test_branding.py b/modules/branding/tests/test_branding.py index ef84ffcd..a59afb6c 100644 --- a/modules/branding/tests/test_branding.py +++ b/modules/branding/tests/test_branding.py @@ -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 == "" @@ -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 "SimpleModule" 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 "Acme Corp" in page.text + assert '' 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 @@ -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", diff --git a/modules/users/tests/test_mailer.py b/modules/users/tests/test_mailer.py index 448e394b..a818fa60 100644 --- a/modules/users/tests/test_mailer.py +++ b/modules/users/tests/test_mailer.py @@ -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"] diff --git a/modules/users/users/mailer/__init__.py b/modules/users/users/mailer/__init__.py index 608418ac..c2c507d7 100644 --- a/modules/users/users/mailer/__init__.py +++ b/modules/users/users/mailer/__init__.py @@ -2,10 +2,22 @@ from __future__ import annotations +from collections.abc import Callable from typing import Protocol, runtime_checkable from users.settings import UsersSettings +#: Returns the live application name (e.g. from the branding module) so emails +#: are branded with the deployment's name rather than the framework default. +AppNameProvider = Callable[[], str] + +#: Fallback app name when no provider is supplied (e.g. branding not installed). +DEFAULT_APP_NAME = "SimpleModule" + + +def default_app_name() -> str: + return DEFAULT_APP_NAME + @runtime_checkable class Mailer(Protocol): @@ -14,7 +26,11 @@ async def send_password_reset(self, email: str, token: str) -> None: ... async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: ... -def build_mailer(settings: UsersSettings) -> Mailer: +def build_mailer( + settings: UsersSettings, + app_name_provider: AppNameProvider | None = None, +) -> Mailer: + provider = app_name_provider or default_app_name if settings.mailer == "smtp": from users.mailer.smtp import SmtpMailer @@ -26,8 +42,9 @@ def build_mailer(settings: UsersSettings) -> Mailer: from_address=settings.smtp_from, use_tls=settings.smtp_tls, base_url=settings.base_url, + app_name_provider=provider, ) from users.mailer.console import ConsoleMailer - return ConsoleMailer(base_url=settings.base_url) + return ConsoleMailer(base_url=settings.base_url, app_name_provider=provider) diff --git a/modules/users/users/mailer/console.py b/modules/users/users/mailer/console.py index 21dffb53..b5a434fc 100644 --- a/modules/users/users/mailer/console.py +++ b/modules/users/users/mailer/console.py @@ -3,25 +3,41 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from users.mailer import AppNameProvider logger = logging.getLogger("users.mailer") class ConsoleMailer: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, app_name_provider: AppNameProvider | None = None) -> None: self._base = base_url.rstrip("/") + from users.mailer import default_app_name + + self._app_name = app_name_provider or default_app_name async def send_verification(self, email: str, token: str) -> None: link = f"{self._base}/users/verify?token={token}" - logger.info("users.verify.email", extra={"to": email, "link": link}) + logger.info( + "users.verify.email", extra={"to": email, "link": link, "app_name": self._app_name()} + ) async def send_password_reset(self, email: str, token: str) -> None: link = f"{self._base}/users/reset-password?token={token}" - logger.info("users.reset.email", extra={"to": email, "link": link}) + logger.info( + "users.reset.email", extra={"to": email, "link": link, "app_name": self._app_name()} + ) async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: link = f"{self._base}/users/invite/accept?token={token}" logger.info( "users.invite.email", - extra={"to": email, "link": link, "invited_by": invited_by_name}, + extra={ + "to": email, + "link": link, + "invited_by": invited_by_name, + "app_name": self._app_name(), + }, ) diff --git a/modules/users/users/mailer/smtp.py b/modules/users/users/mailer/smtp.py index ee446eb4..a6a065e7 100644 --- a/modules/users/users/mailer/smtp.py +++ b/modules/users/users/mailer/smtp.py @@ -4,10 +4,14 @@ import importlib.resources from email.message import EmailMessage +from typing import TYPE_CHECKING import aiosmtplib import jinja2 +if TYPE_CHECKING: + from users.mailer import AppNameProvider + def _load_template_env() -> jinja2.Environment: """Build a Jinja2 Environment pointed at the bundled templates directory.""" @@ -33,6 +37,7 @@ def __init__( from_address: str, use_tls: bool, base_url: str, + app_name_provider: AppNameProvider | None = None, ) -> None: self._host = host self._port = port @@ -41,24 +46,30 @@ def __init__( self._from = from_address self._use_tls = use_tls self._base = base_url.rstrip("/") + from users.mailer import default_app_name + + self._app_name = app_name_provider or default_app_name async def send_verification(self, email: str, token: str) -> None: + app = self._app_name() link = f"{self._base}/users/verify?token={token}" template = _template_env.get_template("verify_email.txt") - body = template.render(link=link) - await self._send(email, "Verify your email address", body) + body = template.render(link=link, app_name=app) + await self._send(email, f"Verify your email for {app}", body) async def send_password_reset(self, email: str, token: str) -> None: + app = self._app_name() link = f"{self._base}/users/reset-password?token={token}" template = _template_env.get_template("reset_password.txt") - body = template.render(link=link) - await self._send(email, "Reset your password", body) + body = template.render(link=link, app_name=app) + await self._send(email, f"Reset your {app} password", body) async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: + app = self._app_name() link = f"{self._base}/users/invite/accept?token={token}" template = _template_env.get_template("invite.txt") - body = template.render(link=link, invited_by_name=invited_by_name) - await self._send(email, f"You've been invited by {invited_by_name}", body) + body = template.render(link=link, invited_by_name=invited_by_name, app_name=app) + await self._send(email, f"{invited_by_name} invited you to {app}", body) async def _send(self, to: str, subject: str, body: str) -> None: message = EmailMessage() diff --git a/modules/users/users/mailer/templates/invite.txt b/modules/users/users/mailer/templates/invite.txt index 3157e0c6..1a042ae1 100644 --- a/modules/users/users/mailer/templates/invite.txt +++ b/modules/users/users/mailer/templates/invite.txt @@ -1 +1,7 @@ -{{ invited_by_name }} invited you. Accept: {{ link }} +{{ invited_by_name }} invited you to join {{ app_name }}. + +Accept the invitation: {{ link }} + +If you weren't expecting this, you can safely ignore this email. + +— The {{ app_name }} team diff --git a/modules/users/users/mailer/templates/reset_password.txt b/modules/users/users/mailer/templates/reset_password.txt index c27c3e29..db4595cb 100644 --- a/modules/users/users/mailer/templates/reset_password.txt +++ b/modules/users/users/mailer/templates/reset_password.txt @@ -1 +1,7 @@ -Reset your password: {{ link }} +We received a request to reset your {{ app_name }} password. + +Reset it here: {{ link }} + +If you didn't request this, you can safely ignore this email — your password won't change. + +— The {{ app_name }} team diff --git a/modules/users/users/mailer/templates/verify_email.txt b/modules/users/users/mailer/templates/verify_email.txt index 88635947..8ee69cf3 100644 --- a/modules/users/users/mailer/templates/verify_email.txt +++ b/modules/users/users/mailer/templates/verify_email.txt @@ -1 +1,7 @@ -Verify your email: {{ link }} +Welcome to {{ app_name }}! + +Verify your email address: {{ link }} + +If you didn't create an account, you can safely ignore this email. + +— The {{ app_name }} team diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 1ed95e00..754cf475 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -176,13 +176,21 @@ async def on_startup(self, app: FastAPI) -> None: from users.backend import reconfigure_cookie_transport from users.bootstrap import bootstrap_admin_from_env from users.deps import auth_backend - from users.mailer import build_mailer + from users.mailer import build_mailer, default_app_name from users.oauth.providers import build_client_map, provider_buttons from users.roles_cache import refresh_roles_cache state = app.state.users s = state.settings - state.mailer = build_mailer(s) + + def _app_name() -> str: + # Read the (optional) branding module's live name off app.state by + # name — never imported, so users stays decoupled from branding. + branding = getattr(app.state, "branding", None) + name = getattr(getattr(branding, "settings", None), "app_name", None) + return name or default_app_name() + + state.mailer = build_mailer(s, _app_name) state.rate_limiter = LoginRateLimiter( max_failures=s.login_rate_limit_failures, window_seconds=s.login_rate_limit_window_seconds, diff --git a/packages/ui/src/components/BrandingFooter.test.tsx b/packages/ui/src/components/BrandingFooter.test.tsx new file mode 100644 index 00000000..ceb01d12 --- /dev/null +++ b/packages/ui/src/components/BrandingFooter.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { BrandingFooter } from './BrandingFooter'; + +describe('BrandingFooter', () => { + test('renders the app name and framework links', () => { + render(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Docs' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'GitHub' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Changelog' })).toBeInTheDocument(); + }); + + test('shows the current year and licence in the caption', () => { + render(); + const year = new Date().getFullYear(); + expect(screen.getByText(new RegExp(`${year}.*MIT`))).toBeInTheDocument(); + }); + + test('renders the uploaded logo when a logoUrl is provided', () => { + render(); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute( + 'src', + '/api/file-storage/files/abc/download', + ); + }); +}); diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx new file mode 100644 index 00000000..257cd400 --- /dev/null +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -0,0 +1,55 @@ +import { BRAND_ACCENT, BRAND_FOOTER_LINKS, BRAND_LICENSE } from '../lib/brand'; +import { BrandingMark } from './BrandingMark'; + +/** Stable for the lifetime of the bundle — the year only matters at page load. */ +const FOOTER_YEAR = new Date().getFullYear(); + +interface BrandingFooterProps { + /** Application name from the `branding` shared prop. */ + appName: string; + /** Custom logo URL; falls back to the generated initial badge. */ + logoUrl?: string | null; + /** + * `public` centres the row within `max-w-6xl` (marketing pages); `app` spans + * the full content width of the sidebar shell. + */ + variant?: 'app' | 'public'; +} + +/** + * App-wide footer: brand lockup on the left, framework links on the right. + * Presentational (props-driven) so it renders without Inertia context and is + * shared by both the authenticated shell and the public layout. + */ +export function BrandingFooter({ + appName, + logoUrl, + variant = 'app', +}: BrandingFooterProps): React.ReactElement { + const container = + variant === 'public' ? 'mx-auto max-w-6xl px-4 py-6 sm:px-8' : 'px-4 py-6 sm:px-6 lg:px-8'; + + return ( + + ); +} diff --git a/packages/ui/src/components/BrandingHead.test.tsx b/packages/ui/src/components/BrandingHead.test.tsx index c9a6bc7b..1330bbbc 100644 --- a/packages/ui/src/components/BrandingHead.test.tsx +++ b/packages/ui/src/components/BrandingHead.test.tsx @@ -15,6 +15,7 @@ beforeEach(() => { state.branding = undefined; document.documentElement.style.removeProperty('--primary'); document.documentElement.style.removeProperty('--sidebar-primary'); + document.documentElement.style.removeProperty('--color-primary-600'); }); afterEach(() => cleanup()); @@ -27,6 +28,38 @@ describe('BrandingHead', () => { expect(document.documentElement.style.getPropertyValue('--sidebar-primary')).toBe('#ff0000'); }); + test('derives the full primary ramp so gradient tints follow the brand', () => { + state.branding = { appName: 'Acme', primaryColor: '#ff0000', logoUrl: null, faviconUrl: null }; + render(); + // The badge gradient uses primary-600/800 — these must be re-themed too. + expect(document.documentElement.style.getPropertyValue('--color-primary-600')).toMatch( + /^oklch\(/, + ); + expect(document.documentElement.style.getPropertyValue('--color-primary-800')).toMatch( + /^oklch\(/, + ); + }); + + test('clears the derived ramp on unmount', () => { + state.branding = { appName: 'Acme', primaryColor: '#ff0000', logoUrl: null, faviconUrl: null }; + const { unmount } = render(); + unmount(); + expect(document.documentElement.style.getPropertyValue('--color-primary-600')).toBe(''); + }); + + test('keeps the server-rendered theme-color meta in sync, restoring on unmount', () => { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute('content', '#000000'); + document.head.appendChild(meta); + state.branding = { appName: 'Acme', primaryColor: '#ff0000', logoUrl: null, faviconUrl: null }; + const { unmount } = render(); + expect(meta.getAttribute('content')).toBe('#ff0000'); + unmount(); + expect(meta.getAttribute('content')).toBe('#000000'); + meta.remove(); + }); + test('renders a favicon link when a faviconUrl is set', () => { state.branding = { appName: 'Acme', diff --git a/packages/ui/src/components/BrandingHead.tsx b/packages/ui/src/components/BrandingHead.tsx index 008c9b00..3f512b51 100644 --- a/packages/ui/src/components/BrandingHead.tsx +++ b/packages/ui/src/components/BrandingHead.tsx @@ -1,17 +1,22 @@ import { Head, usePage } from '@inertiajs/react'; import { useEffect } from 'react'; +import { setTitleAppName } from '../lib/app-title'; +import { deriveBrandRamp } from '../lib/color'; import type { SharedProps } from '../types'; -const COLOR_VARS = ['--primary', '--sidebar-primary'] as const; - /** * Applies branding that lives in the document head / root, on every page: * - * - the favicon `` when a custom favicon is set, and - * - the primary brand colour, written as inline CSS variables on `:root` - * (inline wins over the stylesheet's `:root`/`.dark` rules; `--color-primary` - * already resolves to `var(--primary)`, so Tailwind `primary` utilities pick - * it up site-wide). + * - the favicon `` when a custom favicon is set, + * - the primary brand colour — derived into the full `--color-primary-*` ramp + * (plus base `--primary` / `--sidebar-primary`) and written as inline CSS + * variables on `:root`. Inline wins over the stylesheet's `:root`/`.dark` + * rules, so every Tailwind `primary` utility — solid buttons *and* the + * `primary-600/700/800` gradient tints used by the brand badge — follows the + * configured colour site-wide, + * - the `` (server-rendered, kept in sync here on a + * runtime colour change), and + * - the app name for the document `` suffix on client navigations. * * Reads the `branding` shared prop, so it stays reactive across navigation. */ @@ -19,18 +24,28 @@ export function BrandingHead(): React.ReactElement | null { const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; const primaryColor = branding?.primaryColor ?? null; const faviconUrl = branding?.faviconUrl ?? null; + const appName = branding?.appName ?? null; + + // Keep the title suffix in sync if the app is renamed without a reload. + useEffect(() => { + setTitleAppName(appName); + }, [appName]); useEffect(() => { const root = document.documentElement; - for (const v of COLOR_VARS) { - if (primaryColor) { - root.style.setProperty(v, primaryColor); - } else { - root.style.removeProperty(v); - } - } + const ramp = primaryColor ? deriveBrandRamp(primaryColor) : null; + if (!ramp) return; + const keys = Object.keys(ramp); + for (const k of keys) root.style.setProperty(k, ramp[k]); + + // Keep the (server-rendered) theme-color chrome in sync with a live change. + const meta = document.querySelector('meta[name="theme-color"]'); + const prevThemeColor = meta?.getAttribute('content') ?? null; + if (meta && primaryColor) meta.setAttribute('content', primaryColor); + return () => { - for (const v of COLOR_VARS) root.style.removeProperty(v); + for (const k of keys) root.style.removeProperty(k); + if (meta && prevThemeColor !== null) meta.setAttribute('content', prevThemeColor); }; }, [primaryColor]); diff --git a/packages/ui/src/components/BrandingMark.test.tsx b/packages/ui/src/components/BrandingMark.test.tsx index c340ca44..4787215d 100644 --- a/packages/ui/src/components/BrandingMark.test.tsx +++ b/packages/ui/src/components/BrandingMark.test.tsx @@ -25,4 +25,10 @@ describe('BrandingMark', () => { expect(screen.queryByRole('img')).toBeNull(); expect(screen.getByText('Z')).toBeInTheDocument(); }); + + test('renders an optional caption stacked under the wordmark', () => { + render(<BrandingMark appName="Acme" accentColor="bg-blue-500" caption="python" />); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.getByText('python')).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/components/BrandingMark.tsx b/packages/ui/src/components/BrandingMark.tsx index 7f144eeb..cafdb0c5 100644 --- a/packages/ui/src/components/BrandingMark.tsx +++ b/packages/ui/src/components/BrandingMark.tsx @@ -7,10 +7,16 @@ interface BrandingMarkProps { logoUrl?: string | null; /** Tailwind classes for the fallback badge background (e.g. a gradient). */ accentColor: string; - /** Visual size — `sm` for the mobile bar, `md` for the desktop sidebar. */ - size?: 'sm' | 'md'; + /** Visual size — `sm` mobile bar, `md` desktop sidebar, `lg` auth screens. */ + size?: 'sm' | 'md' | 'lg'; /** Classes for the wordmark text (colour/spacing supplied by the layout). */ labelClassName?: string; + /** Override for the badge shadow (defaults to `shadow-sm`; e.g. a coloured glow). */ + badgeClassName?: string; + /** Optional muted sub-caption stacked under the wordmark (e.g. `python`, `© 2026 · MIT`). */ + caption?: string; + /** Classes for the caption text. Defaults to a muted mono line. */ + captionClassName?: string; } /** @@ -24,28 +30,57 @@ export function BrandingMark({ accentColor, size = 'md', labelClassName, + badgeClassName, + caption, + captionClassName, }: BrandingMarkProps): React.ReactElement { - const box = size === 'sm' ? 'w-7 h-7 rounded-md' : 'w-8 h-8 rounded-lg'; + const box = + size === 'sm' + ? 'w-7 h-7 rounded-md' + : size === 'lg' + ? 'w-9 h-9 rounded-lg' + : 'w-8 h-8 rounded-lg'; const labelSize = size === 'sm' ? 'text-base' : 'text-lg'; + const initialSize = size === 'lg' ? 'text-base' : 'text-xs'; + const badgeShadow = badgeClassName ?? 'shadow-sm'; const initial = appName.trim().charAt(0).toUpperCase() || 'S'; + const wordmark = ( + <span + className={ + labelClassName ?? + `${labelSize} font-semibold text-white font-[var(--font-display)] tracking-tight` + } + > + {appName} + </span> + ); + return ( <> {logoUrl ? ( - <img src={logoUrl} alt={appName} className={`${box} object-contain bg-white/5 shadow-sm`} /> + <img + src={logoUrl} + alt={appName} + className={`${box} object-contain bg-white/5 ${badgeShadow}`} + /> ) : ( - <div className={`${box} ${accentColor} flex items-center justify-center shadow-sm`}> - <span className="text-white font-bold text-xs font-[var(--font-display)]">{initial}</span> + <div className={`${box} ${accentColor} flex items-center justify-center ${badgeShadow}`}> + <span className={`text-white font-bold ${initialSize} font-[var(--font-display)]`}> + {initial} + </span> </div> )} - <span - className={ - labelClassName ?? - `${labelSize} font-semibold text-white font-[var(--font-display)] tracking-tight` - } - > - {appName} - </span> + {caption ? ( + <span className="flex flex-col leading-tight"> + {wordmark} + <span className={captionClassName ?? 'font-mono text-[11px] text-muted-foreground'}> + {caption} + </span> + </span> + ) : ( + wordmark + )} </> ); } diff --git a/packages/ui/src/layouts/AuthCardShell.tsx b/packages/ui/src/layouts/AuthCardShell.tsx index 96bdf181..d7948bcc 100644 --- a/packages/ui/src/layouts/AuthCardShell.tsx +++ b/packages/ui/src/layouts/AuthCardShell.tsx @@ -1,5 +1,9 @@ +import { usePage } from '@inertiajs/react'; import type React from 'react'; import { BrandingHead } from '../components/BrandingHead'; +import { BrandingMark } from '../components/BrandingMark'; +import { BRAND_ACCENT, BRAND_DEFAULT_APP_NAME, BRAND_TECH } from '../lib/brand'; +import type { SharedProps } from '../types'; /** * Full-viewport centered shell for unauthenticated flows @@ -9,6 +13,10 @@ import { BrandingHead } from '../components/BrandingHead'; * SimpleModulePython HiFi auth screens. */ export function AuthCardShell({ children }: { children: React.ReactNode }) { + const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const appName = branding?.appName ?? BRAND_DEFAULT_APP_NAME; + const logoUrl = branding?.logoUrl ?? null; + return ( <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-secondary/40 p-4"> <BrandingHead /> @@ -19,15 +27,15 @@ export function AuthCardShell({ children }: { children: React.ReactNode }) { <div className="relative w-full max-w-md"> <div className="rounded-3xl border border-border bg-white/85 p-7 shadow-xl backdrop-blur-xl backdrop-saturate-150"> <div className="mb-5 flex items-center gap-2.5"> - <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-800 shadow-md shadow-primary-600/30"> - <span className="font-bold text-white text-base font-[var(--font-display)]">S</span> - </div> - <div className="flex flex-col leading-tight"> - <span className="text-[17px] font-bold tracking-tight font-[var(--font-display)] text-foreground"> - simple_module - </span> - <span className="font-mono text-[11px] text-muted-foreground">python</span> - </div> + <BrandingMark + appName={appName} + logoUrl={logoUrl} + accentColor={BRAND_ACCENT} + size="lg" + badgeClassName="shadow-md shadow-primary-600/30" + labelClassName="text-[17px] font-bold tracking-tight font-[var(--font-display)] text-foreground" + caption={BRAND_TECH} + /> </div> {children} </div> diff --git a/packages/ui/src/layouts/PublicLayout.tsx b/packages/ui/src/layouts/PublicLayout.tsx index b7075521..094ceb9e 100644 --- a/packages/ui/src/layouts/PublicLayout.tsx +++ b/packages/ui/src/layouts/PublicLayout.tsx @@ -3,14 +3,16 @@ import { Button } from '@simple-module-py/ui/components/ui/button'; import { Menu, X } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; +import { BrandingFooter } from '../components/BrandingFooter'; import { BrandingHead } from '../components/BrandingHead'; import { LocaleSwitcher } from '../components/LocaleSwitcher'; +import { BRAND_ACCENT, BRAND_DEFAULT_APP_NAME, BRAND_REPO_URL } from '../lib/brand'; import type { SharedProps } from '../types'; export function PublicLayout({ children }: { children: React.ReactNode }) { const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; const [menuOpen, setMenuOpen] = useState(false); - const appName = branding?.appName ?? 'simple_module'; + const appName = branding?.appName ?? BRAND_DEFAULT_APP_NAME; const logoUrl = branding?.logoUrl ?? null; const brandInitial = appName.trim().charAt(0).toUpperCase() || 'S'; @@ -28,7 +30,9 @@ export function PublicLayout({ children }: { children: React.ReactNode }) { className="h-8 w-8 rounded-lg object-contain shadow-md transition-transform group-hover:scale-105" /> ) : ( - <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-800 shadow-md shadow-primary-600/30 transition-transform group-hover:scale-105"> + <div + className={`flex h-8 w-8 items-center justify-center rounded-lg ${BRAND_ACCENT} shadow-md shadow-primary-600/30 transition-transform group-hover:scale-105`} + > <span className="font-bold text-white text-sm font-[var(--font-display)]"> {brandInitial} </span> @@ -41,19 +45,19 @@ export function PublicLayout({ children }: { children: React.ReactNode }) { <div className="hidden items-center gap-4 sm:flex"> <a - href="https://github.com/antosubash/simple_module_python#readme" + href={`${BRAND_REPO_URL}#readme`} className="text-sm text-muted-foreground transition-colors hover:text-foreground" > Docs </a> <a - href="https://github.com/antosubash/simple_module_python/tree/main/modules" + href={`${BRAND_REPO_URL}/tree/main/modules`} className="text-sm text-muted-foreground transition-colors hover:text-foreground" > Modules </a> <a - href="https://github.com/antosubash/simple_module_python" + href={BRAND_REPO_URL} className="text-sm text-muted-foreground transition-colors hover:text-foreground" > GitHub @@ -109,38 +113,7 @@ export function PublicLayout({ children }: { children: React.ReactNode }) { <main className="flex-1">{children}</main> - <footer className="border-t border-border bg-background py-6 px-4 sm:px-8"> - <div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3"> - <div className="flex items-center gap-2.5"> - <div className="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-primary-600 to-primary-800"> - <span className="font-bold text-white text-[10px] font-[var(--font-display)]">S</span> - </div> - <span className="font-mono text-xs text-muted-foreground"> - simple_module_python · MIT - </span> - </div> - <div className="flex gap-5 text-xs text-muted-foreground"> - <a - href="https://github.com/antosubash/simple_module_python#readme" - className="hover:text-foreground transition-colors" - > - Docs - </a> - <a - href="https://github.com/antosubash/simple_module_python/releases" - className="hover:text-foreground transition-colors" - > - Changelog - </a> - <a - href="https://github.com/antosubash/simple_module_python" - className="hover:text-foreground transition-colors" - > - GitHub - </a> - </div> - </div> - </footer> + <BrandingFooter appName={appName} logoUrl={logoUrl} variant="public" /> </div> ); } diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index e2afe57a..47e7de76 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -18,6 +18,7 @@ import { import { ChevronsUpDown } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; +import { BrandingFooter } from '../components/BrandingFooter'; import { BrandingHead } from '../components/BrandingHead'; import { BrandingMark } from '../components/BrandingMark'; import { NavIcon } from '../components/NavIcon'; @@ -281,7 +282,10 @@ export function SidebarLayout({ </aside> {/* Main content */} - <main className="min-h-screen lg:ml-64">{children}</main> + <main className="flex min-h-screen flex-col lg:ml-64"> + <div className="flex-1">{children}</div> + <BrandingFooter appName={appName} logoUrl={logoUrl} variant="app" /> + </main> </div> </TooltipProvider> ); diff --git a/packages/ui/src/lib/app-title.test.ts b/packages/ui/src/lib/app-title.test.ts new file mode 100644 index 00000000..c51ad7c0 --- /dev/null +++ b/packages/ui/src/lib/app-title.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { formatTitle, setTitleAppName } from './app-title'; + +// The app name is module-level state — reset it after each test. +afterEach(() => setTitleAppName(null)); + +describe('formatTitle', () => { + test('defaults to the framework name', () => { + expect(formatTitle('Dashboard')).toBe('Dashboard — SimpleModule'); + expect(formatTitle()).toBe('SimpleModule'); + expect(formatTitle('')).toBe('SimpleModule'); + }); + + test('uses the configured app name once set', () => { + setTitleAppName('Acme'); + expect(formatTitle('Dashboard')).toBe('Dashboard — Acme'); + expect(formatTitle()).toBe('Acme'); + }); + + test('trims and falls back on blank/null names', () => { + setTitleAppName(' Acme '); + expect(formatTitle('X')).toBe('X — Acme'); + setTitleAppName(' '); + expect(formatTitle('X')).toBe('X — SimpleModule'); + setTitleAppName('Acme'); + setTitleAppName(null); + expect(formatTitle('X')).toBe('X — SimpleModule'); + }); +}); diff --git a/packages/ui/src/lib/app-title.ts b/packages/ui/src/lib/app-title.ts new file mode 100644 index 00000000..baa11085 --- /dev/null +++ b/packages/ui/src/lib/app-title.ts @@ -0,0 +1,24 @@ +import { BRAND_DEFAULT_APP_NAME } from './brand'; + +/** + * Document-title formatting for the browser tab. + * + * Inertia's global `title` callback runs outside React (it can't read live page + * props), so the configured app name is held in this module-level cell. It is + * seeded once from the initial page's `branding` shared prop in `app.tsx`, and + * refreshed by `BrandingHead` if the app is renamed without a reload. The + * server also renders the branded name into the static `<title>` of the root + * template, so the pre-hydration tab is already correct. + */ +let appName: string = BRAND_DEFAULT_APP_NAME; + +/** Update the app name used for the title suffix (falls back to the default). */ +export function setTitleAppName(name: string | null | undefined): void { + appName = name?.trim() || BRAND_DEFAULT_APP_NAME; +} + +/** `"Dashboard — Acme"` for a page title, or just `"Acme"` for the bare app. */ +export function formatTitle(pageTitle?: string | null): string { + const trimmed = pageTitle?.trim(); + return trimmed ? `${trimmed} — ${appName}` : appName; +} diff --git a/packages/ui/src/lib/brand.ts b/packages/ui/src/lib/brand.ts new file mode 100644 index 00000000..3b29c021 --- /dev/null +++ b/packages/ui/src/lib/brand.ts @@ -0,0 +1,35 @@ +/** + * Framework-level brand metadata shared across the header, footer, and auth + * shells. These are constants of the *framework/template* itself — distinct + * from the white-labellable `branding` shared prop (`appName`, `logoUrl`, …), + * which a deployment can customise at runtime. + */ +export const BRAND_REPO_URL = 'https://github.com/antosubash/simple_module_python'; + +/** Licence shown in the footer caption. */ +export const BRAND_LICENSE = 'MIT'; + +/** Short technology tag shown beneath the wordmark on auth screens. */ +export const BRAND_TECH = 'python'; + +/** + * Default app name used when the `branding` shared prop is absent (the branding + * module is optional). Mirrors the server-side `DEFAULT_APP_NAME` so the + * unbranded experience is identical across every shell. + */ +export const BRAND_DEFAULT_APP_NAME = 'SimpleModule'; + +/** Tailwind classes for the default brand badge gradient (header / footer / auth lockups). */ +export const BRAND_ACCENT = 'bg-gradient-to-br from-primary-600 to-primary-800'; + +export interface BrandLink { + label: string; + href: string; +} + +/** Links rendered on the right of the application + marketing footers. */ +export const BRAND_FOOTER_LINKS: BrandLink[] = [ + { label: 'Docs', href: `${BRAND_REPO_URL}#readme` }, + { label: 'Changelog', href: `${BRAND_REPO_URL}/releases` }, + { label: 'GitHub', href: BRAND_REPO_URL }, +]; diff --git a/packages/ui/src/lib/color.test.ts b/packages/ui/src/lib/color.test.ts new file mode 100644 index 00000000..b190bf06 --- /dev/null +++ b/packages/ui/src/lib/color.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; +import { BASE_PRIMARY_RAMP, deriveBrandRamp, hexToOklch } from './color'; + +describe('hexToOklch', () => { + test('white is light and (near) achromatic', () => { + const c = hexToOklch('#ffffff'); + expect(c).not.toBeNull(); + expect(c?.l).toBeGreaterThan(0.99); + expect(c?.c).toBeLessThan(0.01); + }); + + test('black is dark', () => { + expect(hexToOklch('#000000')?.l).toBeLessThan(0.01); + }); + + test('red sits in the expected OKLCH hue band (~29°)', () => { + const c = hexToOklch('#ff0000'); + expect(c?.h).toBeGreaterThan(20); + expect(c?.h).toBeLessThan(40); + }); + + test('3-digit and 6-digit hex agree', () => { + expect(hexToOklch('#f00')?.h).toBeCloseTo(hexToOklch('#ff0000')?.h ?? -1, 1); + }); + + test('returns null for malformed input', () => { + expect(hexToOklch('red')).toBeNull(); + expect(hexToOklch('#12')).toBeNull(); + expect(hexToOklch('')).toBeNull(); + }); +}); + +describe('deriveBrandRamp', () => { + test('emits every ramp step plus the base tokens', () => { + const ramp = deriveBrandRamp('#1a7dd1'); + expect(ramp).not.toBeNull(); + for (const { step } of BASE_PRIMARY_RAMP) { + expect(ramp?.[`--color-primary-${step}`]).toMatch(/^oklch\(/); + } + // Base tokens are the exact picked colour. + expect(ramp?.['--primary']).toBe('#1a7dd1'); + expect(ramp?.['--sidebar-primary']).toBe('#1a7dd1'); + }); + + test('a near-grey brand yields a near-grey ramp (low chroma)', () => { + const ramp = deriveBrandRamp('#808080'); + const step600 = ramp?.['--color-primary-600'] ?? ''; + const chroma = Number.parseFloat(step600.split(' ')[1]); + expect(chroma).toBeLessThan(0.02); + }); + + test('a vivid brand keeps meaningful chroma', () => { + const ramp = deriveBrandRamp('#ff0000'); + const step600 = ramp?.['--color-primary-600'] ?? ''; + const chroma = Number.parseFloat(step600.split(' ')[1]); + expect(chroma).toBeGreaterThan(0.1); + }); + + test('returns null for malformed input', () => { + expect(deriveBrandRamp('not-a-color')).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/color.ts b/packages/ui/src/lib/color.ts new file mode 100644 index 00000000..09b93140 --- /dev/null +++ b/packages/ui/src/lib/color.ts @@ -0,0 +1,115 @@ +/** + * Colour helpers for runtime brand theming. + * + * A deployment configures a single brand colour (`#rrggbb`). The app's Tailwind + * theme, however, is a 10-step ramp (`--color-primary-50 … -900`) plus the base + * `--primary` token. To make the *whole* brand surface follow the configured + * colour — buttons (`bg-primary`) **and** the logo-badge gradient + * (`from-primary-600 to-primary-800`) and the auth mesh blobs — we derive the + * full ramp from that one hex at runtime. + * + * Strategy: keep the designed *lightness* ladder exactly (so contrast stays as + * the designer intended), replace the *hue* with the brand colour's hue, and + * scale the *chroma* by how saturated the brand colour is relative to the + * default. A near-grey brand yields a near-grey ramp; a vivid brand yields a + * vivid ramp — both with the original light/dark contrast. + */ + +export interface Oklch { + l: number; + c: number; + h: number; +} + +/** + * The default theme's primary ramp, mirrored from + * `packages/ui/src/styles/globals.css` (`--color-primary-*`). Only lightness and + * chroma are used; the hue is replaced per brand colour. Keep in sync with the + * stylesheet — a unit test pins the step list. + */ +export const BASE_PRIMARY_RAMP: { step: number; l: number; c: number }[] = [ + { step: 50, l: 0.97, c: 0.02 }, + { step: 100, l: 0.93, c: 0.05 }, + { step: 200, l: 0.86, c: 0.09 }, + { step: 300, l: 0.79, c: 0.13 }, + { step: 400, l: 0.71, c: 0.16 }, + { step: 500, l: 0.66, c: 0.16 }, + { step: 600, l: 0.59, c: 0.14 }, + { step: 700, l: 0.5, c: 0.11 }, + { step: 800, l: 0.42, c: 0.09 }, + { step: 900, l: 0.35, c: 0.07 }, +]; + +/** Chroma of the default ramp's 600 step — the reference for chroma scaling. */ +const BASE_REFERENCE_CHROMA = BASE_PRIMARY_RAMP.find((s) => s.step === 600)?.c ?? 0.14; +/** Clamp the chroma scale so a vivid pick can't blow far out of gamut. */ +const MAX_CHROMA_SCALE = 1.8; + +function srgbToLinear(channel: number): number { + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; +} + +/** Parse `#rgb` / `#rrggbb` into linear-light RGB in `[0, 1]`, or null. */ +function parseHex(hex: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return null; + let h = m[1]; + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + const r = Number.parseInt(h.slice(0, 2), 16) / 255; + const g = Number.parseInt(h.slice(2, 4), 16) / 255; + const b = Number.parseInt(h.slice(4, 6), 16) / 255; + return [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]; +} + +/** Convert an `#rrggbb` colour to OKLCH, or null when it can't be parsed. */ +export function hexToOklch(hex: string): Oklch | null { + const lin = parseHex(hex); + if (!lin) return null; + const [r, g, b] = lin; + + // Linear sRGB → OKLab (Björn Ottosson's matrices). + const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + + const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; + const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; + const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; + + const c = Math.hypot(a, bb); + let h = (Math.atan2(bb, a) * 180) / Math.PI; + if (h < 0) h += 360; + return { l: L, c, h }; +} + +function round(n: number, places: number): number { + const f = 10 ** places; + return Math.round(n * f) / f; +} + +function oklchString({ l, c, h }: Oklch): string { + return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`; +} + +/** + * Derive the CSS custom properties that re-theme the primary ramp to `hex`. + * + * Returns a map of `--color-primary-<step>` → `oklch(…)` (plus the base + * `--primary` / `--sidebar-primary` set to the brand colour itself). Returns + * null for an unparseable colour so callers can leave the default theme intact. + */ +export function deriveBrandRamp(hex: string): Record<string, string> | null { + const brand = hexToOklch(hex); + if (!brand) return null; + + const chromaScale = Math.min(brand.c / BASE_REFERENCE_CHROMA, MAX_CHROMA_SCALE); + const vars: Record<string, string> = {}; + for (const { step, l, c } of BASE_PRIMARY_RAMP) { + vars[`--color-primary-${step}`] = oklchString({ l, c: c * chromaScale, h: brand.h }); + } + // Keep the base tokens as the exact picked colour: `bg-primary` should be + // precisely what the admin chose, while the ramp drives gradients/tints. + vars['--primary'] = hex; + vars['--sidebar-primary'] = hex; + return vars; +}