feat: design-pack registry — modules can contribute a site-wide look - #235
Merged
Conversation
A design pack is a stylesheet a module ships that restyles the public site by overriding the base tokens beneath a `<value>-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
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
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
Deploying simple-module-python with
|
| Latest commit: |
7e05883
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ca97eeb4.simple-module-python.pages.dev |
| Branch Preview URL: | https://feat-design-pack-registry.simple-module-python.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the framework half of the design-pack feature. This is the piece
antosubash/simple_module_python_modules#1
is blocked on: its
canopy_atlasmodule importssimple_module_core.design_packs,which doesn't exist in any released or in-repo framework build, so the module
fails to load at boot and that repo's E2E suite goes red on the first test.
What a design pack is
A stylesheet a module ships that restyles the public site by overriding the
base design tokens beneath a
<value>-rootclass. Before this, the only sitewith a pack hard-coded it into
pagebuilder— meaning a generic module shippedone particular site's brand.
Core
simple_module_core.design_packs—DesignPack(value, label)andDesignPackRegistry, sitting besideMenuRegistry/PermissionRegistry/PublicRouteRegistry. Both re-exported from the package root.ModuleBase.register_design_packs(registry)— a new no-op-by-default hook.Servicesgains a requireddesign_packsslot.Two deliberate strictnesses:
^[a-z0-9][a-z0-9-]*$, validated in__post_init__so aninvalid pack can't be constructed at all. The slug becomes a CSS class
fragment, so a module with a bad one fails at import rather than at render.
root class would leave whichever stylesheet loaded last in charge — not
something an administrator could diagnose from the UI.
Registering advertises a pack; it does not load the stylesheet. The CSS still
reaches the bundle via the host's
styles.css. The registry exists so anadministrator can't select a pack nothing provides.
Hosting
create_appcollects every module's packs in dependency order and publishes theregistry on
app.state.design_packs(andapp.state.sm.design_packs).app_builder.pywas sitting one line under the hard 300-line cap, so thehost-settings registration block moved to
_phase_helpers.register_host_settings— splitting by responsibility as CLAUDE.md prescribes rather than squeezing the
file. The hosting suite is byte-identical in outcome across that move
(180 passed / 2 skipped before and after).
Branding
The selected pack is a branding setting, not a per-page property — one site, one
look, read from shared props.
design_packon settings,BrandingOut,BrandingUpdate;designPackin theshared-props payload and the
BrandingSharedTS type.PUT /api/branding/rejects a slug no installed module registered (422). Thecheck lives in the endpoint because only there is
app.state.design_packsreachable; the settings and DTO validators enforce shape alone.
designPackspage prop.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
degrades to an unstyled site rather than refusing to start. Only a live write is
rejected.
The branding view and endpoint both reach the registry via
getattr, so thispublished module still runs against a host older than the registry — it just
offers no packs to choose from.
Testing
Written test-first throughout — every test was watched failing before the code
that satisfies it.
framework/core/tests/test_design_packs.py— 23 tests: slug validation, frozendataclass, registration order, copy-on-read, duplicate rejection, membership.
framework/core/tests/test_module_base.py— hook default no-op + override.framework/hosting/tests/test_design_packs_wiring.py— the registry reachesapp.stateand a module's registered pack lands in it.modules/branding/tests/test_design_pack.py— 16 tests across settings, DTO,payload, and the API's accept / reject / clear / leave-untouched behaviour.
Verified locally:
make lintexit 0 (ruff format + ruff + ty + biome + per-workspacetsc + 300-line cap + metadata/README checks),
1505 passed, 2 skippedon pytest,41 passedon vitest.make doctorreports SM020 (bothusersandkeycloakauth providers installedin the dev workspace) and two SM003 orphan pages in
keycloak/audit_log—all pre-existing and untouched by this change.
After merge
Cutting the release is the maintainer's call. Once
simple_module_core/simple_module_hosting/simple_module_brandingship a version carrying this,the modules repo can move its pins and PR #1's E2E suite unblocks.