From c210df7379906da7cfa87c60c64c5250b8d400b2 Mon Sep 17 00:00:00 2001
From: Anto Subash
Date: Tue, 4 Aug 2026 14:51:24 +0200
Subject: [PATCH 1/3] feat(core,hosting): add a design-pack registry and its
ModuleBase hook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A design pack is a stylesheet a module ships that restyles the public site
by overriding the base tokens beneath a `-root` class. Modules had no
way to advertise one, so the only site with a pack hard-coded it into
pagebuilder — which meant pagebuilder shipped one particular site's brand.
`DesignPackRegistry` is the extension point, sitting beside MenuRegistry,
PermissionRegistry and PublicRouteRegistry. `create_app` collects every
module's packs in dependency order and publishes the registry on
`app.state.design_packs` (and `app.state.sm.design_packs`).
Two deliberate strictnesses:
- A slug must match `^[a-z0-9][a-z0-9-]*$`. It becomes a CSS class fragment,
so anything else either fails to select or selects something unintended.
Validated in `__post_init__`, so an invalid pack can't be constructed at
all — a module with a bad slug fails at import, not at first render.
- Registering a slug twice raises rather than overwriting. Two packs sharing
one root class would leave whichever stylesheet loaded last in charge,
which is not diagnosable from the UI.
The registry advertises packs; it does not load stylesheets. A pack's CSS
still reaches the bundle through the host's `styles.css`. Its job is to stop
an administrator selecting a pack no installed module provides.
`app_builder.py` sat one line under the repo's hard 300-line cap, so the
host-settings registration block moves to `_phase_helpers.register_host_settings`
— splitting by responsibility as CLAUDE.md prescribes rather than squeezing
the file. The hosting suite is unchanged across that move (180 passed,
2 skipped before and after).
`Services` gains a required `design_packs` slot, keeping "one slot per owner"
honest; the two test construction sites are updated to match.
Claude-Session: https://claude.ai/code/session_01S5xZgDWnBt6EzWv5G8XbNG
---
framework/core/simple_module_core/__init__.py | 3 +
.../core/simple_module_core/design_packs.py | 88 +++++++++++++++
framework/core/simple_module_core/module.py | 17 +++
framework/core/simple_module_core/services.py | 2 +
framework/core/tests/test_design_packs.py | 105 ++++++++++++++++++
framework/core/tests/test_module_base.py | 22 ++++
framework/core/tests/test_services.py | 3 +
.../simple_module_hosting/_phase_helpers.py | 28 +++++
.../simple_module_hosting/app_builder.py | 34 ++----
.../hosting/tests/test_design_packs_wiring.py | 52 +++++++++
.../settings/tests/test_module_settings.py | 1 +
11 files changed, 333 insertions(+), 22 deletions(-)
create mode 100644 framework/core/simple_module_core/design_packs.py
create mode 100644 framework/core/tests/test_design_packs.py
create mode 100644 framework/hosting/tests/test_design_packs_wiring.py
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/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]
From 4d34b000e66f2029a439b1d18924d73163df3b24 Mon Sep 17 00:00:00 2001
From: Anto Subash
Date: Tue, 4 Aug 2026 14:51:35 +0200
Subject: [PATCH 2/3] feat(branding): let an administrator select the site's
design pack
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pack is a branding setting rather than a per-page property: one site has
one look, and the public page should read it from shared props instead of
digging into a page's root props.
- `BrandingSettings.design_pack` ("" = base tokens only), plus the field on
`BrandingOut` / `BrandingUpdate` and `designPack` in the shared-props
payload and the `BrandingShared` TS type.
- `PUT /api/branding/` rejects a slug no installed module registered. The
check lives in the endpoint because only there is
`request.app.state.design_packs` reachable; the settings and DTO validators
enforce shape alone.
- The Manage page gets a pack dropdown, fed by a `designPacks` page prop from
the branding view — the choices depend on which modules are installed, so
they can't come from shared props.
Shape and registration are checked in different places on purpose. Settings
are hydrated from the DB at boot, so a pack whose module has since been
uninstalled must degrade to an unstyled site rather than refuse to start;
only a live write is rejected.
Both the view and the endpoint reach `app.state.design_packs` through
`getattr`, so this published module still runs against a host older than the
registry — it simply offers no packs to choose from.
`DesignPackField` lives under `components/`, not `pages/`, since anything
under `pages/` is treated as a real Inertia page (SM003).
Claude-Session: https://claude.ai/code/session_01S5xZgDWnBt6EzWv5G8XbNG
---
.../branding/components/DesignPackField.tsx | 58 ++++++++
modules/branding/branding/constants.py | 9 ++
.../branding/branding/contracts/schemas.py | 22 ++-
modules/branding/branding/endpoints/api.py | 29 +++-
modules/branding/branding/endpoints/views.py | 17 ++-
modules/branding/branding/locales/en.json | 4 +
modules/branding/branding/pages/Manage.tsx | 22 ++-
modules/branding/branding/service.py | 1 +
modules/branding/branding/settings.py | 19 ++-
modules/branding/branding/shared_props.py | 1 +
modules/branding/tests/test_branding.py | 1 +
modules/branding/tests/test_design_pack.py | 137 ++++++++++++++++++
packages/i18n/src/generated-resources.ts | 4 +
packages/i18n/src/keys.generated.ts | 4 +
packages/ui/src/types.ts | 4 +
15 files changed, 322 insertions(+), 10 deletions(-)
create mode 100644 modules/branding/branding/components/DesignPackField.tsx
create mode 100644 modules/branding/tests/test_design_pack.py
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/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;
}
From 7e0588325c4e96a76e167e5ff8b96f090fd69f18 Mon Sep 17 00:00:00 2001
From: Anto Subash
Date: Tue, 4 Aug 2026 14:51:45 +0200
Subject: [PATCH 3/3] docs: document the design-pack extension point
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the hook to the boot sequence in the lifecycle and overview docs, and a
"Design packs" section to framework-conventions beside "Public routes" —
covering what registering does and doesn't do (advertises a pack, does not
load its stylesheet), the slug rules, and why the selection is a branding
setting scoped to the public site.
Claude-Session: https://claude.ai/code/session_01S5xZgDWnBt6EzWv5G8XbNG
---
docs/framework-conventions.md | 31 +++++++++++++++++++++++++++++++
docs/framework/lifecycle.md | 1 +
docs/framework/overview.md | 2 +-
3 files changed, 33 insertions(+), 1 deletion(-)
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.