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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ docs/.vitepress/dist/
host/client_app/modules.manifest.json
host/client_app/modules.generated.ts
host/client_app/modules.generated.css
host/client_app/modules.assets.json

# Worktrees
.worktrees/
Expand Down
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,15 @@ modules/<name>/<name>/
├── endpoints/api.py # REST (JSON)
├── endpoints/views.py # Inertia view endpoints
├── pages/*.tsx # auto-discovered by Vite via modules.generated.ts
├── theme.css # optional — @theme tokens; imported UNLAYERED
├── styles.css # optional — component rules; imported into layer(components)
└── locales/<lang>.json
```
Both CSS files are optional and auto-detected; `gen-pages` emits an
`@import "#module/<pkg>/..."` for each, so nothing is added to the host's
`styles.css` by hand. The split is load-bearing: a `@theme` block inside a
cascade layer is inert, while unlayered CSS beats every Tailwind utility —
hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.

**Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed:
`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)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).
Expand Down Expand Up @@ -94,7 +101,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b

## Diagnostic codes

Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.<module>` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn). In production, errors fail boot.
Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.<module>` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn), `SM022` `@theme`/`@custom-variant`/`@utility` in a module's `styles.css`, where `layer(components)` makes them inert (warn), `SM023` an unlayered rule in a module's `theme.css`, which outranks every Tailwind utility (warn). In production, errors fail boot.

## Tests & fixtures

Expand Down
80 changes: 80 additions & 0 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ Modules may ship TSX pages in `my_module/pages/*.tsx`. On host boot (and on
- `client_app/modules.manifest.json` — machine-readable paths
- `client_app/modules.generated.ts` — per-module `import.meta.glob`
calls with absolute paths resolved via `importlib.resources`
- `client_app/modules.generated.css` — Tailwind `@source` entries, plus an
`@import` per module-shipped stylesheet (see [Styling](#styling))
- `client_app/modules.assets.json` — the per-module asset record that
`vite.config.ts` builds its `#module/<pkg>` aliases from

Vite's `server.fs.allow` is extended to cover each installed module's
package root, so pages shipped inside a wheel work for the dev server and
Expand All @@ -263,6 +267,82 @@ class MyModule(ModuleBase):

The host mounts each entry as `StaticFiles` during boot.

## Styling

A module may ship two optional stylesheets beside its `pages/` directory.
Both are auto-detected exactly the way `pages/` is — there is no hook to
override and nothing to register:

```
my_module/
├── module.py
├── theme.css # optional — @theme tokens, @custom-variant, @font-face
├── styles.css # optional — component rules, keyframes, vendor CSS
└── pages/
```

`smpy host gen-pages` emits an `@import` for each into
`client_app/modules.generated.css`:

```css
@import "#module/my_module/theme.css";
@import "#module/my_module/styles.css" layer(components);
```

**Nothing needs to be added to the host's `styles.css` by hand.** The
`#module/<pkg>` specifier is a Vite alias built from `modules.assets.json`,
so it resolves identically whether the module is a workspace member or
installed from a wheel — and no generated file ends up containing a
`../../../.venv/lib/python3.12/site-packages/...` path that would break the
next time the interpreter version changes.

Imports are emitted in module discovery order, which is topological by
`ModuleMeta.depends_on`. A module that depends on another can therefore
override its dependency's styles.

### Which file does what

The split is not cosmetic — it is what makes the cascade rules structural
rather than merely documented.

| | `theme.css` | `styles.css` |
|---|---|---|
| Imported | unlayered | `layer(components)` |
| For | `@theme`, `@custom-variant`, `@utility`, `@font-face`, `:root` tokens | component rules, keyframes, vendor CSS |
| Beats a Tailwind utility? | yes | no |

Tailwind v4 expands `@import "tailwindcss"` into
`@layer theme, base, components, utilities`, and **unlayered CSS beats every
layered rule**. So a module shipping a bare `.card { padding: 0 }` unlayered
would silently override `p-4` on that element. But `@theme` blocks *must* be
unlayered to register design tokens at all — a `@theme` inside a layer is
inert. One file cannot satisfy both constraints, so each file gets one job.

`make doctor` catches the two ways to get this wrong: **SM022** flags
`@theme`/`@custom-variant`/`@utility` sitting in `styles.css` (where they do
nothing), and **SM023** flags an unlayered rule in `theme.css` (where it
outranks every utility). Both are warnings — the CSS is legal either way, it
just cascades in a way you probably did not intend.

### Cascade order

```
design-system @theme < module theme.css < app @theme overrides
```

A module normally *adds* tokens (`--color-map-water`); when it deliberately
redefines a design-system token it wins, and the consuming app still has the
final word from its own `@theme` block below the generated import.

### Packaging

**No packaging change is required.** The module wheel template already
declares `[tool.hatch.build.targets.wheel] packages = ["my_module"]`, and
Hatch includes every file under the package directory — `.css` along with
`.tsx`. The `force-include` block is only needed for artifacts that live
*outside* the package dir (`package.json`) or that are gitignored
(`static/dist`).

## Templates

Jinja2 template directories contributed via `ModuleBase.template_dirs()`
Expand Down
Loading
Loading