diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md
index d943bc4a..f7ea307c 100644
--- a/docs/framework-conventions.md
+++ b/docs/framework-conventions.md
@@ -300,6 +300,37 @@ which `AuthMiddleware` consults on every request. See
[`docs/framework/public-routes.md`](framework/public-routes.md) for match kinds
and resolution order.
+### Design packs (site-wide look)
+
+A *design pack* is a stylesheet a module ships that restyles the public site by
+overriding the base design tokens beneath a `-root` class. A module
+advertises the packs it provides via `register_design_packs`:
+
+```python
+def register_design_packs(self, registry):
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
+```
+
+The host aggregates them into one `DesignPackRegistry` and publishes it at
+`app.state.design_packs` (also `app.state.sm.design_packs`). Branding reads it
+twice: its admin page builds the pack dropdown from it, and `PUT /api/branding/`
+rejects a slug no installed module registered.
+
+**Registering advertises the pack; it does not load the stylesheet.** The CSS
+still reaches the bundle through the host's `styles.css` importing it by package
+specifier. The registry exists so an administrator can't select a pack nothing
+provides — that would put a class on the document with no rules behind it and
+silently do nothing.
+
+Slugs must match `^[a-z0-9][a-z0-9-]*$` (they become a CSS class fragment) and
+are unique across modules — a collision raises at boot rather than letting
+whichever stylesheet loaded last win.
+
+The selected pack is a **branding setting**, not a per-page property: one site
+has one look. It reaches the frontend as `branding.designPack` in shared props,
+and applies to the public site only — putting the pack class on the admin would
+restyle the editor chrome along with it.
+
## Events
Base class: `Event` from `simple_module_core.events`. Subclass per domain event:
diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md
index fe2c3b75..cbed6fac 100644
--- a/docs/framework/lifecycle.md
+++ b/docs/framework/lifecycle.md
@@ -12,6 +12,7 @@ register_feature_flags
register_event_handlers
register_health_checks
register_public_routes
+register_design_packs
register_exception_handlers
register_middleware
register_routes
diff --git a/docs/framework/overview.md b/docs/framework/overview.md
index 40de7a6c..939fa066 100644
--- a/docs/framework/overview.md
+++ b/docs/framework/overview.md
@@ -20,7 +20,7 @@ What actually happens when you run `uvicorn main:app`:
`Settings`, `DatabaseState` (engines per provider), `EventBus`, `MenuRegistry`, `PermissionRegistry`, `FeatureFlagRegistry`, `HealthRegistry`, `I18nRegistry`. They are bundled into a frozen `Services` dataclass and attached to `app.state.sm`.
3. **Discovery** — `discover_modules()` reads Python entry points under the `simple_module` group, imports each one, validates it's a `ModuleBase` subclass with a non-null `meta`, and topologically sorts by `ModuleMeta.depends_on`.
4. **Lifecycle hooks run in sorted order**. For each module, in this order:
- `register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`.
+ `register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes` → `register_design_packs` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`.
5. **Middleware is installed** — framework middleware first, then whatever modules registered. See [Middleware pipeline](/framework/middleware).
6. **Routers mount** — `api_router` at `/api`, `view_router` at `/`. Each module's sub-routers were attached via `register_routes`.
7. **Lifespan `on_startup`** — each module's async `on_startup` runs in dependency order. This is where background workers, warm caches, or remote-service health probes start.
diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py
index f2085720..f01de2f9 100644
--- a/framework/core/simple_module_core/__init__.py
+++ b/framework/core/simple_module_core/__init__.py
@@ -1,5 +1,6 @@
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
+from simple_module_core.design_packs import DesignPack, DesignPackRegistry
from simple_module_core.diagnostics import (
DiagnosticLevel,
MigrationDiagnostics,
@@ -40,6 +41,8 @@
__all__ = [
"FRAMEWORK_API_VERSION",
"CircularDependencyError",
+ "DesignPack",
+ "DesignPackRegistry",
"DiagnosticLevel",
"Event",
"EventBus",
diff --git a/framework/core/simple_module_core/design_packs.py b/framework/core/simple_module_core/design_packs.py
new file mode 100644
index 00000000..55ca0c60
--- /dev/null
+++ b/framework/core/simple_module_core/design_packs.py
@@ -0,0 +1,88 @@
+"""Design-pack registry — modules contribute a selectable look for the public site.
+
+A *design pack* is a stylesheet a module ships that restyles the reader-facing
+site by overriding the base design tokens beneath a ``-root`` class. A
+module declares the packs it provides via
+:meth:`~simple_module_core.module.ModuleBase.register_design_packs`; the host
+collects them into one registry at boot and stores it on
+``app.state.design_packs``.
+
+**The registry supplies the dropdown, not the stylesheet.** A pack's CSS still
+reaches the bundle through the host's ``styles.css`` importing it by package
+specifier. The registry's only job is to stop an administrator selecting a pack
+that no installed module provides — which would otherwise put a class on the
+document with nothing behind it and silently do nothing.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
+"""A pack slug: lowercase alphanumerics and dashes, not starting with a dash.
+
+The site root class is ``f"{value}-root"``, so the slug has to be usable as a
+bare CSS identifier fragment.
+"""
+
+
+@dataclass(frozen=True)
+class DesignPack:
+ """One selectable look.
+
+ Args:
+ value: Slug identifying the pack. The public site's root element
+ carries ``f"{value}-root"``, which is the hook the pack's own
+ stylesheet selects on.
+ label: Human-readable name, shown in the branding dropdown.
+ """
+
+ value: str
+ label: str
+
+ def __post_init__(self) -> None:
+ if not SLUG_RE.match(self.value):
+ raise ValueError(
+ f"DesignPack value {self.value!r} is not a usable class-name "
+ f"fragment; it must match {SLUG_RE.pattern}"
+ )
+
+
+class DesignPackRegistry:
+ """Aggregates every module's :class:`DesignPack` declarations.
+
+ Populated once during boot (``register_design_packs`` hook) and read
+ thereafter — by the branding view to build its dropdown, and by the
+ branding API to validate a submitted slug.
+ """
+
+ def __init__(self) -> None:
+ self._packs: dict[str, DesignPack] = {}
+
+ def register(self, pack: DesignPack) -> None:
+ """Add *pack*, rejecting a slug another module already claimed.
+
+ Duplicate slugs are an error rather than a silent overwrite: two packs
+ sharing one root class would leave whichever stylesheet happened to
+ load last in charge, which is not something an administrator could
+ diagnose from the UI.
+ """
+ existing = self._packs.get(pack.value)
+ if existing is not None:
+ raise ValueError(
+ f"Design pack {pack.value!r} is already registered "
+ f"(as {existing.label!r}); slugs must be unique across modules"
+ )
+ self._packs[pack.value] = pack
+
+ def all(self) -> list[DesignPack]:
+ """Every registered pack, in registration order (a copy)."""
+ return list(self._packs.values())
+
+ def has(self, value: str) -> bool:
+ """Return ``True`` if some module registered a pack with this slug."""
+ return value in self._packs
+
+
+__all__ = ["DesignPack", "DesignPackRegistry"]
diff --git a/framework/core/simple_module_core/module.py b/framework/core/simple_module_core/module.py
index c7cec220..d10ee0ca 100644
--- a/framework/core/simple_module_core/module.py
+++ b/framework/core/simple_module_core/module.py
@@ -10,6 +10,7 @@
if TYPE_CHECKING:
from fastapi import APIRouter, FastAPI
+ from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.events import EventBus
from simple_module_core.feature_flags import FeatureFlagRegistry
from simple_module_core.health import HealthRegistry
@@ -137,6 +138,22 @@ def register_public_routes(self, registry):
mutations. Called once at boot, in dependency order.
"""
+ def register_design_packs(self, registry: DesignPackRegistry) -> None:
+ """Declare design packs this module ships for the public site.
+
+ A design pack is a stylesheet that restyles the reader-facing site by
+ overriding the base tokens beneath a ``-root`` class. Override
+ this hook to make the pack selectable in branding::
+
+ def register_design_packs(self, registry):
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
+
+ Registering only advertises the pack — the stylesheet itself still
+ reaches the bundle through the host's ``styles.css``. Slugs are unique
+ across modules; a collision raises at boot. Called once, in dependency
+ order.
+ """
+
def register_middleware(self, app: FastAPI) -> None:
"""Add middleware to the application.
diff --git a/framework/core/simple_module_core/services.py b/framework/core/simple_module_core/services.py
index d8126221..cb86c11c 100644
--- a/framework/core/simple_module_core/services.py
+++ b/framework/core/simple_module_core/services.py
@@ -20,6 +20,7 @@
from simple_module_db.session import DatabaseState
from simple_module_hosting.settings import Settings
+ from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.events import EventBus
from simple_module_core.feature_flags import FeatureFlagRegistry
from simple_module_core.health import HealthRegistry
@@ -42,6 +43,7 @@ class Services:
feature_flags: FeatureFlagRegistry
health_registry: HealthRegistry
public_routes: PublicRouteRegistry
+ design_packs: DesignPackRegistry
i18n_registry: I18nRegistry
inertia_config: InertiaConfig
modules: tuple[ModuleBase, ...]
diff --git a/framework/core/tests/test_design_packs.py b/framework/core/tests/test_design_packs.py
new file mode 100644
index 00000000..627272b5
--- /dev/null
+++ b/framework/core/tests/test_design_packs.py
@@ -0,0 +1,105 @@
+"""Tests for DesignPackRegistry — modules contribute site-wide design packs.
+
+A design pack is a stylesheet a module ships that restyles the public site by
+overriding the base tokens beneath a ``-root`` class. The registry is
+what lets branding offer an administrator only the packs an installed module
+actually provides: selecting one nothing ships would put a class on the
+document with no stylesheet behind it, silently doing nothing.
+
+The registry supplies the dropdown, not the stylesheet — a pack's CSS still
+reaches the bundle through the host's ``styles.css``.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+
+import pytest
+from simple_module_core.design_packs import DesignPack, DesignPackRegistry
+
+
+class TestDesignPack:
+ def test_carries_slug_and_label(self):
+ pack = DesignPack(value="gca", label="Canopy Atlas")
+ assert pack.value == "gca"
+ assert pack.label == "Canopy Atlas"
+
+ def test_is_frozen(self):
+ pack = DesignPack(value="gca", label="Canopy Atlas")
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ pack.value = "other"
+
+ @pytest.mark.parametrize("value", ["gca", "g", "7", "canopy-atlas", "a1-b2"])
+ def test_accepts_a_css_safe_slug(self, value):
+ assert DesignPack(value=value, label="X").value == value
+
+ @pytest.mark.parametrize(
+ "value",
+ ["", "-gca", "GCA", "canopy_atlas", "canopy atlas", "gca!", "gca.pack"],
+ )
+ def test_rejects_a_slug_that_would_not_survive_a_class_name(self, value):
+ # The site root class is f"{value}-root", so anything that isn't a bare
+ # lowercase CSS identifier fragment either fails to select or, worse,
+ # silently selects something else.
+ with pytest.raises(ValueError, match="value"):
+ DesignPack(value=value, label="X")
+
+
+class TestDesignPackRegistry:
+ def test_starts_empty(self):
+ assert DesignPackRegistry().all() == []
+
+ def test_registered_pack_is_returned(self):
+ registry = DesignPackRegistry()
+ pack = DesignPack(value="gca", label="Canopy Atlas")
+ registry.register(pack)
+ assert registry.all() == [pack]
+
+ def test_all_preserves_registration_order(self):
+ registry = DesignPackRegistry()
+ first = DesignPack(value="gca", label="Canopy Atlas")
+ second = DesignPack(value="aurora", label="Aurora")
+ registry.register(first)
+ registry.register(second)
+ assert registry.all() == [first, second]
+
+ def test_all_returns_a_copy(self):
+ registry = DesignPackRegistry()
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
+ registry.all().clear()
+ assert len(registry.all()) == 1
+
+ def test_duplicate_value_is_rejected(self):
+ # Two modules claiming one root class would leave whichever stylesheet
+ # loaded last in charge — a silent overwrite is the wrong answer.
+ registry = DesignPackRegistry()
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
+ with pytest.raises(ValueError, match="gca"):
+ registry.register(DesignPack(value="gca", label="Someone Else's Pack"))
+
+ def test_distinct_values_sharing_a_label_are_allowed(self):
+ registry = DesignPackRegistry()
+ registry.register(DesignPack(value="gca", label="Atlas"))
+ registry.register(DesignPack(value="gca-dark", label="Atlas"))
+ assert [p.value for p in registry.all()] == ["gca", "gca-dark"]
+
+ def test_has_reports_membership(self):
+ # Branding's PUT validates the submitted slug against this.
+ registry = DesignPackRegistry()
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
+ assert registry.has("gca")
+ assert not registry.has("aurora")
+
+ def test_has_is_false_on_an_empty_registry(self):
+ assert not DesignPackRegistry().has("gca")
+
+
+class TestPublicSurface:
+ def test_both_names_are_exported_from_the_package_root(self):
+ # Module authors import registries from ``simple_module_core``, the
+ # same way they reach MenuRegistry or PublicRouteRegistry.
+ import simple_module_core
+
+ assert simple_module_core.DesignPack is DesignPack
+ assert simple_module_core.DesignPackRegistry is DesignPackRegistry
+ assert {"DesignPack", "DesignPackRegistry"} <= set(simple_module_core.__all__)
diff --git a/framework/core/tests/test_module_base.py b/framework/core/tests/test_module_base.py
index 33164a13..8c5d7d35 100644
--- a/framework/core/tests/test_module_base.py
+++ b/framework/core/tests/test_module_base.py
@@ -125,6 +125,28 @@ def register_public_routes(self, registry):
assert reg.matches("GET", "/api/with-public/datasets/9/tilejson")
assert not reg.matches("PATCH", "/api/with-public/datasets/9/tilejson")
+ async def test_register_design_packs_default_noop(self):
+ from simple_module_core.design_packs import DesignPackRegistry
+
+ mod = DummyModule()
+ reg = DesignPackRegistry()
+ mod.register_design_packs(reg)
+ assert reg.all() == []
+
+ async def test_register_design_packs_override(self):
+ from simple_module_core.design_packs import DesignPack, DesignPackRegistry
+
+ class ModWithPack(ModuleBase):
+ meta = ModuleMeta(name="WithPack")
+
+ def register_design_packs(self, registry):
+ registry.register(DesignPack(value="with-pack", label="With Pack"))
+
+ reg = DesignPackRegistry()
+ ModWithPack().register_design_packs(reg)
+ assert reg.has("with-pack")
+ assert [p.label for p in reg.all()] == ["With Pack"]
+
class TestModuleAssetHooks:
async def test_template_dirs_default_empty(self):
diff --git a/framework/core/tests/test_services.py b/framework/core/tests/test_services.py
index b5fa8ffc..384a598d 100644
--- a/framework/core/tests/test_services.py
+++ b/framework/core/tests/test_services.py
@@ -30,6 +30,7 @@ async def test_services_round_trip_field_access(self) -> None:
assert s.feature_flags is _SENTINEL_FLAGS
assert s.health_registry is _SENTINEL_HEALTH
assert s.public_routes is _SENTINEL_PUBLIC_ROUTES
+ assert s.design_packs is _SENTINEL_DESIGN_PACKS
assert s.i18n_registry is _SENTINEL_I18N
assert s.inertia_config is _SENTINEL_INERTIA
assert s.modules == ()
@@ -43,6 +44,7 @@ async def test_services_round_trip_field_access(self) -> None:
_SENTINEL_FLAGS = object()
_SENTINEL_HEALTH = object()
_SENTINEL_PUBLIC_ROUTES = object()
+_SENTINEL_DESIGN_PACKS = object()
_SENTINEL_I18N = object()
_SENTINEL_INERTIA = object()
@@ -58,6 +60,7 @@ def _make_services() -> Services:
feature_flags=_SENTINEL_FLAGS, # type: ignore[arg-type]
health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
+ design_packs=_SENTINEL_DESIGN_PACKS, # type: ignore[arg-type]
i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
modules=(),
diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py
index decc8a17..80d37cc2 100644
--- a/framework/hosting/simple_module_hosting/_phase_helpers.py
+++ b/framework/hosting/simple_module_hosting/_phase_helpers.py
@@ -32,6 +32,8 @@
request_validation_error_handler,
unhandled_exception_handler,
)
+from simple_module_hosting._host_services import _HostServices
+from simple_module_hosting.host_settings import HostSettings
from simple_module_hosting.i18n_middleware import LocaleMiddleware
from simple_module_hosting.middleware import (
CorrelationIdMiddleware,
@@ -165,6 +167,32 @@ def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
)
+def register_host_settings(app: FastAPI) -> None:
+ """Register host-level settings under ``package="host"`` (DB-backed).
+
+ The Settings module must already have run ``register_settings`` — topo
+ order puts it early, since its ``meta.depends_on`` is empty. When the
+ Settings module isn't enabled there's no registry to register against, so
+ this skips quietly.
+
+ ``settings.registration`` is resolved via importlib rather than a plain
+ ``from settings.registration import ...``: the SM009 coupling check is
+ AST-based and forbids any static import of a plugin package name from
+ within ``framework/*``. Dynamic resolution keeps the framework AST
+ plugin-free while still hitting the real helper at runtime.
+ """
+ if not hasattr(app.state, "settings"):
+ return
+
+ import importlib
+
+ register_module_settings = importlib.import_module(
+ "settings.registration"
+ ).register_module_settings
+
+ register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
+
+
def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]:
"""SM012: warn if a module overrides register_settings but added nothing to app.state.
diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py
index fb53ba81..c2d57815 100644
--- a/framework/hosting/simple_module_hosting/app_builder.py
+++ b/framework/hosting/simple_module_hosting/app_builder.py
@@ -10,6 +10,7 @@
from pathlib import Path
from fastapi import FastAPI
+from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
from simple_module_core.discovery import discover_modules, topological_sort
from simple_module_core.events import EventBus
@@ -22,7 +23,6 @@
from simple_module_db.listeners import register_listeners
from simple_module_db.session import init_db
-from simple_module_hosting._host_services import _HostServices
from simple_module_hosting._inertia_setup import setup_inertia
from simple_module_hosting._phase_helpers import (
attach_public_routes,
@@ -30,10 +30,10 @@
install_middleware,
mount_module_static_dirs,
register_exception_handlers,
+ register_host_settings,
wire_module_routes,
)
from simple_module_hosting.health import router as health_router
-from simple_module_hosting.host_settings import HostSettings
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
from simple_module_hosting.migrations import check_migrations
from simple_module_hosting.settings import Settings
@@ -165,6 +165,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
event_bus = EventBus()
health_registry = HealthRegistry()
public_route_registry = PublicRouteRegistry()
+ design_pack_registry = DesignPackRegistry()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@@ -203,25 +204,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
for mod in modules:
mod.register_settings(app)
- # Register host-level settings under package="host" (DB-backed). The
- # Settings module must already have run register_settings (topo order
- # puts it early; its meta.depends_on = [] so it's among the first).
- # When the Settings module isn't enabled, there's no registry to
- # register against — skip quietly.
- #
- # We resolve `settings.registration` via importlib rather than a plain
- # `from settings.registration import ...`: the SM009 coupling check is
- # AST-based and forbids any static import of a plugin package name
- # from within framework/* code. Dynamic resolution keeps the framework
- # AST plugin-free while still hitting the real helper at runtime.
- if hasattr(app.state, "settings"):
- import importlib
-
- _register_module_settings = importlib.import_module(
- "settings.registration"
- ).register_module_settings
-
- _register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
+ register_host_settings(app)
if settings.is_development:
settings_diagnostics = check_settings_registration(app, modules)
@@ -236,17 +219,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
_register_event_handlers(mod, event_bus, app)
mod.register_health_checks(health_registry)
mod.register_public_routes(public_route_registry)
+ mod.register_design_packs(design_pack_registry)
attach_public_routes(app, settings, public_route_registry)
+ # Branding reads the packs off app.state directly: its API validates a
+ # submitted slug before persisting it, and its view builds the dropdown.
+ app.state.design_packs = design_pack_registry
+
logger.info(
"Registered %d menu items, %d permissions, %d feature flags, "
- "%d health checks, %d public routes",
+ "%d health checks, %d public routes, %d design packs",
len(menu_registry.all_items),
len(perm_registry.all_permissions),
len(ff_registry.all_flags),
len(health_registry.all_checks),
len(public_route_registry.routes),
+ len(design_pack_registry.all()),
)
# ── Phase 6: Initialize database ───────────────────────
@@ -291,6 +280,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
feature_flags=ff_registry,
health_registry=health_registry,
public_routes=public_route_registry,
+ design_packs=design_pack_registry,
i18n_registry=i18n_registry,
inertia_config=inertia_config,
modules=tuple(modules),
diff --git a/framework/hosting/tests/test_design_packs_wiring.py b/framework/hosting/tests/test_design_packs_wiring.py
new file mode 100644
index 00000000..ef178b88
--- /dev/null
+++ b/framework/hosting/tests/test_design_packs_wiring.py
@@ -0,0 +1,52 @@
+"""``create_app`` must collect module design packs onto ``app.state.design_packs``.
+
+Branding reads this registry twice — the view builds its dropdown from it, and
+the API validates a submitted slug against it. If the hook were never called,
+or the populated registry never reached ``app.state``, branding would offer an
+empty dropdown and reject every pack, with nothing in the logs to explain why.
+"""
+
+from __future__ import annotations
+
+import simple_module_hosting.app_builder as app_builder
+from simple_module_core.design_packs import DesignPack, DesignPackRegistry
+from simple_module_core.module import ModuleBase, ModuleMeta
+from simple_module_hosting.app_builder import create_app
+
+_PACK = DesignPack(value="pack-fixture", label="Pack Fixture")
+
+
+class _PackModule(ModuleBase):
+ """A module whose only contribution is one design pack."""
+
+ meta = ModuleMeta(name="PackFixture")
+
+ def register_design_packs(self, registry: DesignPackRegistry) -> None:
+ registry.register(_PACK)
+
+
+def test_registry_is_published_on_app_state(settings):
+ app = create_app(settings)
+ assert isinstance(app.state.design_packs, DesignPackRegistry)
+
+
+def test_module_registered_pack_reaches_app_state(monkeypatch, settings):
+ real_discover = app_builder.discover_modules
+
+ def _with_pack_module(*args, **kwargs):
+ return [*real_discover(*args, **kwargs), _PackModule()]
+
+ monkeypatch.setattr(app_builder, "discover_modules", _with_pack_module)
+
+ app = create_app(settings)
+
+ assert app.state.design_packs.has("pack-fixture")
+ assert _PACK in app.state.design_packs.all()
+
+
+def test_registry_is_also_reachable_through_services(settings):
+ # ``app.state.sm`` is the aggregate every other registry is published on;
+ # design packs should not be the one exception a module author has to
+ # remember a different lookup for.
+ app = create_app(settings)
+ assert app.state.sm.design_packs is app.state.design_packs
diff --git a/modules/branding/branding/components/DesignPackField.tsx b/modules/branding/branding/components/DesignPackField.tsx
new file mode 100644
index 00000000..162cc0c9
--- /dev/null
+++ b/modules/branding/branding/components/DesignPackField.tsx
@@ -0,0 +1,58 @@
+import { keys, useT } from '@simple-module-py/i18n';
+import { Label } from '@simple-module-py/ui/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@simple-module-py/ui/components/ui/select';
+
+export interface DesignPackOption {
+ value: string;
+ label: string;
+}
+
+/** Sentinel for "no pack" — Radix Select forbids an empty-string item value. */
+const NONE = '__none__';
+
+interface DesignPackFieldProps {
+ /** Packs the installed modules registered. Empty means nothing to choose. */
+ options: DesignPackOption[];
+ /** Currently selected slug; '' for base tokens only. */
+ value: string;
+ onChange: (next: string) => void;
+ disabled: boolean;
+}
+
+export function DesignPackField({ options, value, onChange, disabled }: DesignPackFieldProps) {
+ const { t } = useT();
+
+ return (
+
+ );
+}
diff --git a/modules/branding/branding/constants.py b/modules/branding/branding/constants.py
index 577c9ff4..e104aa5e 100644
--- a/modules/branding/branding/constants.py
+++ b/modules/branding/branding/constants.py
@@ -6,6 +6,7 @@
from typing import Final
from file_storage.constants import PATH_FILE_DOWNLOAD, ROUTE_PREFIX_API
+from simple_module_core.design_packs import SLUG_RE
VIEW_PREFIX: Final = "/branding"
# Trailing slash: the browse route is registered at "/" under VIEW_PREFIX, so
@@ -32,6 +33,14 @@
HEX_COLOR_RE: Final = re.compile(r"^#[0-9a-fA-F]{6}$")
MAX_APP_NAME_LEN: Final = 60
+# A design-pack slug. Aliased from the framework's own pattern so branding and
+# DesignPack can never disagree about what a valid slug is.
+DESIGN_PACK_RE: Final = SLUG_RE
+DESIGN_PACK_ERROR: Final = (
+ "design_pack must be a lowercase slug (letters, digits and dashes, "
+ "not starting with a dash) or empty"
+)
+
# Image upload guard-rails (enforced before handing the file to file_storage).
MAX_IMAGE_BYTES: Final = 2 * 1024 * 1024 # 2 MB
ALLOWED_IMAGE_TYPES: Final = frozenset(
diff --git a/modules/branding/branding/contracts/schemas.py b/modules/branding/branding/contracts/schemas.py
index b1edb767..31847d1d 100644
--- a/modules/branding/branding/contracts/schemas.py
+++ b/modules/branding/branding/contracts/schemas.py
@@ -5,7 +5,13 @@
from pydantic import field_validator
from sqlmodel import Field, SQLModel
-from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN, clean_app_name
+from branding.constants import (
+ DESIGN_PACK_ERROR,
+ DESIGN_PACK_RE,
+ HEX_COLOR_RE,
+ MAX_APP_NAME_LEN,
+ clean_app_name,
+)
class BrandingOut(SQLModel):
@@ -13,6 +19,7 @@ class BrandingOut(SQLModel):
app_name: str
primary_color: str = ""
+ design_pack: str = ""
logo_url: str | None = None
favicon_url: str | None = None
@@ -22,6 +29,7 @@ class BrandingUpdate(SQLModel):
app_name: str | None = Field(default=None, max_length=MAX_APP_NAME_LEN)
primary_color: str | None = Field(default=None)
+ design_pack: str | None = Field(default=None)
@field_validator("app_name")
@classmethod
@@ -41,3 +49,15 @@ def _valid_hex(cls, value: str | None) -> str | None:
if value != "" and not HEX_COLOR_RE.match(value):
raise ValueError("primary_color must be a #rrggbb hex string or empty")
return value.lower()
+
+ @field_validator("design_pack")
+ @classmethod
+ def _valid_pack_slug(cls, value: str | None) -> str | None:
+ # Shape only, so a malformed slug is a 422 rather than a 500 when
+ # BrandingSettings re-validates. Registration is checked in the
+ # endpoint, which can reach ``app.state.design_packs``.
+ if value is None:
+ return None
+ if value != "" and not DESIGN_PACK_RE.match(value):
+ raise ValueError(DESIGN_PACK_ERROR)
+ return value
diff --git a/modules/branding/branding/endpoints/api.py b/modules/branding/branding/endpoints/api.py
index 9de0ad83..93cedc42 100644
--- a/modules/branding/branding/endpoints/api.py
+++ b/modules/branding/branding/endpoints/api.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from fastapi import APIRouter, Depends, HTTPException, UploadFile
+from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from file_storage.deps import get_file_storage_service
from file_storage.service import FileStorageService
from simple_module_hosting.permissions import RequiresPermission
@@ -29,16 +29,41 @@ def _validate_image(file: UploadFile) -> None:
)
+def _validate_design_pack(request: Request, changes: dict) -> None:
+ """Reject a pack slug no installed module registered.
+
+ Clearing (``""``) is always allowed. Accepting an unknown slug would put
+ ``"-root"`` on the public document with no stylesheet behind it: the
+ site would look unchanged and nothing in the UI would explain why.
+
+ The registry lives on ``app.state`` rather than in the DTO because only the
+ running app knows which modules are installed. A host older than the
+ registry has no packs to offer, so every non-empty slug is unknown there.
+ """
+ pack = changes.get("design_pack")
+ if not pack:
+ return
+ registry = getattr(request.app.state, "design_packs", None)
+ if registry is None or not registry.has(pack):
+ raise HTTPException(
+ status_code=422,
+ detail=f"Unknown design pack {pack!r} — no installed module provides it.",
+ )
+
+
@router.get("/", response_model=BrandingOut, dependencies=[_MANAGE])
async def get_branding(service: BrandingServiceDep) -> BrandingOut:
return service.current()
@router.put("/", response_model=BrandingOut, dependencies=[_MANAGE])
-async def update_branding(data: BrandingUpdate, service: BrandingServiceDep) -> BrandingOut:
+async def update_branding(
+ request: Request, data: BrandingUpdate, service: BrandingServiceDep
+) -> BrandingOut:
changes = {k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None}
if not changes:
return service.current()
+ _validate_design_pack(request, changes)
return await service.apply(changes)
diff --git a/modules/branding/branding/endpoints/views.py b/modules/branding/branding/endpoints/views.py
index 6961141b..3aa12841 100644
--- a/modules/branding/branding/endpoints/views.py
+++ b/modules/branding/branding/endpoints/views.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, Request
from inertia import InertiaResponse
from simple_module_hosting.inertia_deps import InertiaDep
from simple_module_hosting.permissions import RequiresPermission
@@ -17,13 +17,22 @@
response_model=None,
dependencies=[Depends(RequiresPermission(constants.PERM_VIEW))],
)
-async def manage(inertia: InertiaDep) -> InertiaResponse:
- # Current branding is delivered through the shared ``branding`` prop
- # (the branding shared-props provider), so no page props are needed.
+async def manage(request: Request, inertia: InertiaDep) -> InertiaResponse:
+ # Current branding is delivered through the shared ``branding`` prop (the
+ # branding shared-props provider). The one thing that can't come from
+ # there is the *set of choices* for the design pack: it depends on which
+ # modules are installed, which only the app knows.
+ #
+ # ``getattr`` rather than direct access so this published module still
+ # renders on a host older than the design-pack registry — it just offers
+ # no packs to choose from.
+ registry = getattr(request.app.state, "design_packs", None)
+ packs = registry.all() if registry is not None else []
# The page name is inlined as a literal (rather than constants._PAGE_MANAGE)
# so the SM003/SM004 diagnostics — which do static AST analysis and can't
# resolve attribute access — pair this call with pages/Manage.tsx. A unit
# test asserts the literal matches constants._PAGE_MANAGE.
return await inertia.render(
"Branding/Manage",
+ {"designPacks": [{"value": p.value, "label": p.label} for p in packs]},
)
diff --git a/modules/branding/branding/locales/en.json b/modules/branding/branding/locales/en.json
index fbc94005..012ab903 100644
--- a/modules/branding/branding/locales/en.json
+++ b/modules/branding/branding/locales/en.json
@@ -6,6 +6,10 @@
"app_name_help": "Shown in the sidebar, the browser tab and on sign-in.",
"primary_color_label": "Primary colour",
"primary_color_help": "Accent colour used across buttons and highlights. Leave blank for the default.",
+ "design_pack_label": "Design pack",
+ "design_pack_help": "Restyles the public site. Packs come from the modules you have installed.",
+ "design_pack_none": "None (base tokens)",
+ "design_pack_empty": "No installed module provides a design pack.",
"logo_label": "Logo",
"logo_help": "Square PNG or SVG works best. Replaces the default badge in the sidebar.",
"favicon_label": "Favicon",
diff --git a/modules/branding/branding/pages/Manage.tsx b/modules/branding/branding/pages/Manage.tsx
index 5de2ab62..53b90655 100644
--- a/modules/branding/branding/pages/Manage.tsx
+++ b/modules/branding/branding/pages/Manage.tsx
@@ -15,6 +15,7 @@ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedL
import type { SharedProps } from '@simple-module-py/ui/types';
import { type ChangeEvent, useRef, useState } from 'react';
import { toast } from 'sonner';
+import { DesignPackField, type DesignPackOption } from '../components/DesignPackField';
const DEFAULT_SWATCH = '#10b981';
type ImageKind = 'logo' | 'favicon';
@@ -92,11 +93,17 @@ function ImageField({
function Manage() {
const { t } = useT();
- const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
+ // ``designPacks`` is a page prop, not a shared one: the choices depend on
+ // which modules the host has installed, so only the view can supply them.
+ const page = usePage<{ props: SharedProps }>().props as unknown as SharedProps & {
+ designPacks?: DesignPackOption[];
+ };
+ const { auth, branding } = page;
const canManage = auth?.permissions?.includes('branding.manage');
const [appName, setAppName] = useState(branding?.appName ?? '');
const [color, setColor] = useState(branding?.primaryColor ?? '');
+ const [designPack, setDesignPack] = useState(branding?.designPack ?? '');
const [busy, setBusy] = useState(false);
async function run(work: () => Promise, errorMsg: string) {
@@ -119,7 +126,11 @@ function Manage() {
fetch('/api/branding/', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ app_name: appName, primary_color: color }),
+ body: JSON.stringify({
+ app_name: appName,
+ primary_color: color,
+ design_pack: designPack,
+ }),
}),
t(keys.branding.manage.error_toast),
);
@@ -203,6 +214,13 @@ function Manage() {
+
+
BrandingOut:
return BrandingOut(
app_name=settings.app_name,
primary_color=settings.primary_color,
+ design_pack=settings.design_pack,
logo_url=file_url(settings.logo_file_id),
favicon_url=file_url(settings.favicon_file_id),
)
diff --git a/modules/branding/branding/settings.py b/modules/branding/branding/settings.py
index 8f5e3de7..6513c045 100644
--- a/modules/branding/branding/settings.py
+++ b/modules/branding/branding/settings.py
@@ -14,7 +14,12 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
-from branding.constants import HEX_COLOR_RE, clean_app_name
+from branding.constants import (
+ DESIGN_PACK_ERROR,
+ DESIGN_PACK_RE,
+ HEX_COLOR_RE,
+ clean_app_name,
+)
DEFAULT_APP_NAME = "SimpleModule"
@@ -28,6 +33,7 @@ class BrandingSettings(BaseSettings):
primary_color: str = "" # "" = use the theme default; otherwise "#rrggbb"
logo_file_id: str = "" # file_storage UUID, "" = no custom logo
favicon_file_id: str = "" # file_storage UUID, "" = no custom favicon
+ design_pack: str = "" # "" = base tokens only; otherwise a registered slug
@field_validator("app_name")
@classmethod
@@ -40,3 +46,14 @@ def _valid_hex(cls, value: str) -> str:
if value and not HEX_COLOR_RE.match(value):
raise ValueError("primary_color must be a #rrggbb hex string or empty")
return value.lower()
+
+ @field_validator("design_pack")
+ @classmethod
+ def _valid_pack_slug(cls, value: str) -> str:
+ # Shape only — whether a module still provides this pack is checked in
+ # the endpoint. These settings are also hydrated from the DB at boot,
+ # where a pack whose module has since been uninstalled must degrade to
+ # an unstyled site rather than refuse to start.
+ if value and not DESIGN_PACK_RE.match(value):
+ raise ValueError(DESIGN_PACK_ERROR)
+ return value
diff --git a/modules/branding/branding/shared_props.py b/modules/branding/branding/shared_props.py
index 9b648847..2860f67e 100644
--- a/modules/branding/branding/shared_props.py
+++ b/modules/branding/branding/shared_props.py
@@ -27,6 +27,7 @@ def branding_payload(settings: BrandingSettings) -> dict:
return {
"appName": settings.app_name,
"primaryColor": settings.primary_color or None,
+ "designPack": settings.design_pack or None,
"logoUrl": file_url(settings.logo_file_id),
"faviconUrl": file_url(settings.favicon_file_id),
}
diff --git a/modules/branding/tests/test_branding.py b/modules/branding/tests/test_branding.py
index a59afb6c..57bdf636 100644
--- a/modules/branding/tests/test_branding.py
+++ b/modules/branding/tests/test_branding.py
@@ -54,6 +54,7 @@ def test_branding_payload_unset() -> None:
assert payload == {
"appName": "SimpleModule",
"primaryColor": None,
+ "designPack": None,
"logoUrl": None,
"faviconUrl": None,
}
diff --git a/modules/branding/tests/test_design_pack.py b/modules/branding/tests/test_design_pack.py
new file mode 100644
index 00000000..df97b48a
--- /dev/null
+++ b/modules/branding/tests/test_design_pack.py
@@ -0,0 +1,137 @@
+"""Branding's ``design_pack`` — the site-wide look, chosen by an administrator.
+
+The pack is a branding setting rather than a per-page property: one site has
+one look, and the public page reads it from shared props. Branding owns the
+*selection*; the packs available to select come from whichever modules
+registered them at boot (``app.state.design_packs``).
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+from branding.contracts.schemas import BrandingUpdate
+from branding.settings import BrandingSettings
+from branding.shared_props import branding_payload
+from simple_module_core.design_packs import DesignPack
+
+_GCA = DesignPack(value="gca", label="Canopy Atlas")
+
+
+# ── Unit: settings validation ──────────────────────────────────────────
+
+
+def test_defaults_to_no_pack() -> None:
+ # "" means base tokens only — a site with no pack installed is the norm.
+ assert BrandingSettings().design_pack == ""
+
+
+def test_accepts_a_slug_shaped_value() -> None:
+ assert BrandingSettings(design_pack="gca").design_pack == "gca"
+ assert BrandingSettings(design_pack="canopy-atlas").design_pack == "canopy-atlas"
+
+
+@pytest.mark.parametrize("bad", ["GCA", "canopy atlas", "-gca", "canopy_atlas"])
+def test_rejects_a_value_that_is_not_a_css_class_fragment(bad: str) -> None:
+ # The public root element carries f"{design_pack}-root"; anything that
+ # isn't a bare lowercase identifier fragment can't select.
+ with pytest.raises(ValueError):
+ BrandingSettings(design_pack=bad)
+
+
+def test_settings_validator_does_not_check_registration() -> None:
+ # Shape only. Whether a module actually ships this pack is the endpoint's
+ # job — settings are also hydrated from the DB at boot, where a pack whose
+ # module was since uninstalled must not crash the app.
+ assert BrandingSettings(design_pack="not-installed").design_pack == "not-installed"
+
+
+# ── Unit: shared-props payload ─────────────────────────────────────────
+
+
+def test_payload_reports_no_pack_as_none() -> None:
+ assert branding_payload(BrandingSettings())["designPack"] is None
+
+
+def test_payload_carries_the_selected_pack() -> None:
+ payload = branding_payload(BrandingSettings(design_pack="gca"))
+ assert payload["designPack"] == "gca"
+
+
+# ── Unit: DTO ──────────────────────────────────────────────────────────
+
+
+def test_update_dto_accepts_a_pack_and_a_clear() -> None:
+ assert BrandingUpdate(design_pack="gca").design_pack == "gca"
+ assert BrandingUpdate(design_pack="").design_pack == ""
+
+
+def test_update_dto_rejects_a_bad_shape() -> None:
+ with pytest.raises(ValueError):
+ BrandingUpdate(design_pack="Not A Slug")
+
+
+# ── Integration: API validates against the registry ────────────────────
+
+
+async def test_update_accepts_a_registered_pack(
+ app, authenticated_client: httpx.AsyncClient
+) -> None:
+ app.state.design_packs.register(_GCA)
+
+ resp = await authenticated_client.put("/api/branding/", json={"design_pack": "gca"})
+
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["design_pack"] == "gca"
+ assert app.state.branding.settings.design_pack == "gca"
+
+
+async def test_update_rejects_a_pack_no_module_provides(
+ authenticated_client: httpx.AsyncClient,
+) -> None:
+ # Accepting it would put "aurora-root" on the document with no stylesheet
+ # behind it — the site would look unchanged and nothing would say why.
+ resp = await authenticated_client.put("/api/branding/", json={"design_pack": "aurora"})
+
+ assert resp.status_code == 422, resp.text
+ assert "aurora" in resp.text
+
+
+async def test_update_allows_clearing_the_pack(
+ app, authenticated_client: httpx.AsyncClient
+) -> None:
+ app.state.design_packs.register(_GCA)
+ await authenticated_client.put("/api/branding/", json={"design_pack": "gca"})
+
+ resp = await authenticated_client.put("/api/branding/", json={"design_pack": ""})
+
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["design_pack"] == ""
+ assert app.state.branding.settings.design_pack == ""
+
+
+async def test_a_pack_left_unmentioned_is_untouched(
+ app, authenticated_client: httpx.AsyncClient
+) -> None:
+ app.state.design_packs.register(_GCA)
+ await authenticated_client.put("/api/branding/", json={"design_pack": "gca"})
+
+ await authenticated_client.put("/api/branding/", json={"app_name": "Acme Corp"})
+
+ assert app.state.branding.settings.design_pack == "gca"
+
+
+# ── Integration: the view supplies the dropdown options ────────────────
+
+
+async def test_manage_page_receives_the_registered_packs(
+ app, authenticated_client: httpx.AsyncClient
+) -> None:
+ app.state.design_packs.register(_GCA)
+
+ page = await authenticated_client.get("/branding/", follow_redirects=False)
+
+ assert page.status_code == 200, page.text
+ # Inertia serialises page props into the shell's data-page attribute.
+ assert "designPacks" in page.text
+ assert "Canopy Atlas" in page.text
diff --git a/modules/settings/tests/test_module_settings.py b/modules/settings/tests/test_module_settings.py
index fb58d27a..ab71e154 100644
--- a/modules/settings/tests/test_module_settings.py
+++ b/modules/settings/tests/test_module_settings.py
@@ -48,6 +48,7 @@ def test_collect_exposes_type_requires_restart_group():
feature_flags=None, # type: ignore[arg-type]
health_registry=None, # type: ignore[arg-type]
public_routes=None, # type: ignore[arg-type]
+ design_packs=None, # type: ignore[arg-type]
i18n_registry=None, # type: ignore[arg-type]
inertia_config=None, # type: ignore[arg-type]
modules=(_DemoModule(),), # type: ignore[arg-type]
diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts
index 148f2683..2a79c229 100644
--- a/packages/i18n/src/generated-resources.ts
+++ b/packages/i18n/src/generated-resources.ts
@@ -79,6 +79,10 @@ export default {
'branding.manage.app_name_help': '',
'branding.manage.app_name_label': '',
'branding.manage.description': '',
+ 'branding.manage.design_pack_empty': '',
+ 'branding.manage.design_pack_help': '',
+ 'branding.manage.design_pack_label': '',
+ 'branding.manage.design_pack_none': '',
'branding.manage.error_toast': '',
'branding.manage.favicon_help': '',
'branding.manage.favicon_label': '',
diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts
index 26bddb50..fb055a3b 100644
--- a/packages/i18n/src/keys.generated.ts
+++ b/packages/i18n/src/keys.generated.ts
@@ -114,6 +114,10 @@ export const keys = {
app_name_help: 'branding.manage.app_name_help',
app_name_label: 'branding.manage.app_name_label',
description: 'branding.manage.description',
+ design_pack_empty: 'branding.manage.design_pack_empty',
+ design_pack_help: 'branding.manage.design_pack_help',
+ design_pack_label: 'branding.manage.design_pack_label',
+ design_pack_none: 'branding.manage.design_pack_none',
error_toast: 'branding.manage.error_toast',
favicon_help: 'branding.manage.favicon_help',
favicon_label: 'branding.manage.favicon_label',
diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts
index 2557b805..82e49ec3 100644
--- a/packages/ui/src/types.ts
+++ b/packages/ui/src/types.ts
@@ -9,6 +9,10 @@ export interface MenuItem {
export interface BrandingShared {
appName: string;
primaryColor: string | null;
+ // Slug of the site-wide design pack, or null for base tokens only. The
+ // public site wraps its document in `${designPack}-root`, which is the hook
+ // the owning module's stylesheet selects on.
+ designPack: string | null;
logoUrl: string | null;
faviconUrl: string | null;
}