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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<value>-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:
Expand Down
1 change: 1 addition & 0 deletions docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions framework/core/simple_module_core/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -40,6 +41,8 @@
__all__ = [
"FRAMEWORK_API_VERSION",
"CircularDependencyError",
"DesignPack",
"DesignPackRegistry",
"DiagnosticLevel",
"Event",
"EventBus",
Expand Down
88 changes: 88 additions & 0 deletions framework/core/simple_module_core/design_packs.py
Original file line number Diff line number Diff line change
@@ -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 ``<value>-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"]
17 changes: 17 additions & 0 deletions framework/core/simple_module_core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ``<value>-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.

Expand Down
2 changes: 2 additions & 0 deletions framework/core/simple_module_core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, ...]
105 changes: 105 additions & 0 deletions framework/core/tests/test_design_packs.py
Original file line number Diff line number Diff line change
@@ -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 ``<value>-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__)
22 changes: 22 additions & 0 deletions framework/core/tests/test_module_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions framework/core/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ()
Expand All @@ -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()

Expand All @@ -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=(),
Expand Down
Loading
Loading