Skip to content

Commit 87f635a

Browse files
authored
feat: let modules ship CSS that reaches the host's Tailwind build (#238)
* docs(spec): design for module-shipped CSS after packaging Wheel-installed modules can only contribute Tailwind @source scanning today; there is no way to ship real CSS. Documents the agreed design: a theme.css/styles.css convention, cascade rules enforced by the split, per-module Vite aliases so no generated path contains ../.., an additive modules.assets.json, and SM022/SM023 diagnostics. Claude-Session: https://claude.ai/code/session_01RotaWUR7nhG5JwV59B1Znh * docs(plan): implementation plan for module CSS packaging * docs(plan): record resolver spike results for module CSS Verified against the installed @tailwindcss/vite 4.2.4 / vite 8.0.10 that resolve.alias governs CSS @import and that absolute @source scans files outside the Vite root. Also confirmed the theme/styles layering split and that server.fs.allow is not required for module CSS, since @import is inlined at transform time. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * feat(hosting): discover per-module theme.css/styles.css assets A module may now ship two optional stylesheets beside its pages/ directory, auto-detected exactly the way pages/ already is: theme.css — @theme tokens, @custom-variant, @font-face styles.css — component rules, keyframes, vendor CSS compute_module_assets() preserves discover_modules() order, which is topological by depends_on, so a dependent module's CSS can override its dependency's. A module is included if it contributes any of pages, theme or styles — CSS-only modules never appear in modules.manifest.json, which is keyed off pages/ alone. Lives in its own module rather than manifest.py, which is already near the repo's 300-line cap. The render helpers here are inert until the next commit wires manifest.py to them. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * feat(hosting): emit aliased module CSS imports and modules.assets.json gen-pages now emits an @import per module-shipped stylesheet alongside the existing @source lines, so installing a module no longer means hand-editing the host's styles.css with a path that only resolves for in-repo modules. theme.css is imported unlayered so its @theme blocks register as design tokens; styles.css is imported into layer(components) so a module rule can never outrank a Tailwind utility. Emission follows discovery order, which is topological by depends_on. Stylesheets are referenced as "#module/<pkg>/styles.css" rather than by path, so nothing generated contains a ../../../.venv/lib/python3.12/... string that would break on a Python version bump. The alias targets come from the new additive modules.assets.json; modules.manifest.json keeps its exact {name: pages_dir} shape because every downstream app reads it from its own copy of vite.config.ts. @source also switches from a bare directory to a {ts,tsx} glob, keeping .py files out of the scan. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * feat(client): resolve module CSS through per-module Vite aliases vite.config.ts reads the new modules.assets.json and registers a "#module/<pkg>" alias per module, which is what the @import lines in modules.generated.css resolve against. Aliases are sorted longest-find-first so "#module/gis" cannot shadow "#module/gis_extra". Reading assets.json rather than manifest.json matters: the manifest is keyed off pages/, so a module shipping only CSS never appears in it. The in-repo @source glob widens from pages/** to **, so Tailwind also scans shared components, hooks and Puck blocks. Scanning pages/ alone meant a class used only outside pages/ reached the bundle purely by luck — when some other scanned file happened to use it too. The {ts,tsx} filter keeps .py out. Mirrored into the scaffold templates so new `smpy new` apps get this from the start, and modules.assets.json is gitignored alongside the other generated manifests. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * test(client): assert module-shipped CSS reaches the built bundle Every unit test around render_modules_css can stay green while Tailwind emits nothing, so this drives a real `npm run build` and asserts a class defined only in modules/dashboard/dashboard/styles.css — referenced by no TSX anywhere — survives into the output. It can only get there through the generated @import and the #module alias. Verified the failure mode is loud rather than silent: with the alias removed the build fails outright on an unresolvable import. Skipped when node_modules or npm is missing. CI's python-tests job runs `make install-py` only, so an unconditional build would fail there. Also confirms nothing had to change in the module's Hatch config: dashboard's wheel already ships dashboard/styles.css, since packages = ["dashboard"] includes every file under the package dir. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * feat(doctor): add SM022/SM023 for misplaced module CSS Which of the two files a construct lands in decides how it cascades, and both mistakes produce legal CSS that simply behaves unexpectedly — so both codes are warnings: SM022 @theme/@custom-variant/@Utility in styles.css, which is imported into layer(components) where they are inert SM023 an unlayered rule in theme.css, which outranks every Tailwind utility Detection is a brace-depth-tracking scan rather than a CSS parse, so no parser joins the runtime dependencies just to power a lint. Comments are blanked newline-for-newline so reported line numbers stay accurate. :root-based selectors are allowed in theme.css — including comma-separated and attribute-qualified forms like :root[data-theme="dark"] — since that is how design tokens are normally declared. Line numbers ride in the `file` field as path:line, the convention already used by _inertia_api.py, rather than growing a new field on Diagnostic. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * docs: document the module theme.css/styles.css convention Adds a Styling section to the module authoring guide covering the two-file convention, why the split is load-bearing rather than cosmetic (a @theme inside a cascade layer is inert, and unlayered CSS beats every utility), the resulting DS < module < app cascade order, and the fact that no Hatch change is needed because packages = ["<pkg>"] already ships .css. Also records the convention in CLAUDE.md's module tree, adds SM022/SM023 to the diagnostic-code list, and mentions it in the scaffold README template — which is where module authors will look, since the scaffold deliberately does not create the CSS files empty. Plan steps are checked off; the ruff reformat of the plan is ruff 0.16 formatting Python code blocks inside Markdown. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * fix(doctor): keep braces inside strings out of the CSS depth counter Code review caught a real hole in the SM022/SM023 scanner: it treated every { and } as structural, including ones inside a quoted value. An icon-font rule like .icon { content: "{"; } left the depth counter permanently one level deep, so every later top-level construct read as nested and was silently dropped — swallowing exactly the misplaced @theme that SM022 exists to catch. The mirror case, a "}" in a string, closed a block early and produced a phantom SM023 on a stray quote. The scanner now consumes comments and quoted strings inline (handling both quote styles and backslash escapes) instead of pre-stripping comments with a regex, which also stops a brace inside a comment from shifting depth. Regression tests cover all four shapes. Two smaller review findings: - The scaffold template used existsSync + readFileSync for modules.assets.json while the host copy used try/catch. e955afd deliberately replaced that pattern (TOCTOU + double syscall) in the host config; new code should not reintroduce it. Both now match. - The alias-sort comment claimed the sort stops "#module/gis" shadowing "#module/gis_extra". Vite matches a string `find` on exact equality or a /-bounded prefix, so that shadowing was never possible. The sort is kept for deterministic ordering and the comment now says so. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * fix(hosting): keep generated module CSS formatter-clean biome ci lints the whole tree and modules.generated.css is untracked but not exempt, so the doubled blank line after the header failed make lint for anyone who had run gen-pages. Claude-Session: https://claude.ai/code/session_01RotaWUR7nhG5JwV59B1Znh * fix(doctor,client): bound CSS strings at newlines; keep tests/ out of @source Two findings from the confirming review pass. 1. The string handling added in 4cd528d fixed brace-in-a-string but opened a worse hole: an *unmatched* quote left the scanner inside a string forever, so every later brace stopped counting and all remaining findings vanished — the same silent-swallow failure, new trigger. It fired on an ordinary typo and, more plausibly, on the lone apostrophe in an unquoted url(data:image/svg+xml,...it's...) token. Verified both against the pre-fix scanner, which handled them correctly by ignoring quotes entirely. A CSS string cannot contain a raw newline — an unescaped one ends it. The scanner now honours that, bounding a stray quote to one line. Escaped newlines still continue a string. 2. The widened @source glob `modules/*/*/**` matches any directory under modules/<name>/, not just the Python package, so a module's sibling tests/ was scanned too. Confirmed on a real build: a class used only in modules/dashboard/tests/ reached the production bundle. Harmless in this repo today (module tests are pure pytest) but the same glob ships in the app scaffold, and downstream apps do have Playwright TS under tests/. Excluded via `@source not`, verified supported in Tailwind 4.2.4. A build check confirms the package-dir class is still picked up, the tests-dir one is not, and module-shipped CSS still reaches the bundle. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * fix(doctor): only open a CSS string when its quote closes on the same line The newline bound in the previous commit was not enough. When the closing brace sits on the stray quote's OWN line, it is consumed as string content before the newline ever arrives, so depth stays desynced for the rest of the file anyway: .icon { background: url(it's.png); } @theme { --a: 1; } <- silently missed That single-line form is the more common way such URLs are written, so the hole the previous commit set out to close was still open. A quote now opens a string only if its partner appears before end-of-line — the rule CSS itself applies. An unmatched quote is just an ordinary character, which is exactly how the original brace-only scanner behaved and why it never had this class of bug. The persistent quote state is gone. Escaped newlines still continue a string, so multi-line values keep working, and grid-template-areas (several complete strings across lines) is covered by a regression test. Also documents the one naming constraint the @source exclusion implies: a module's Python package must not itself be named `tests`. No glob can distinguish a package directory from a sibling, and `tests` is not a viable package name regardless — it collides with pytest collection. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu * perf(doctor): consume CSS escapes outside strings, and split the scanner tests The same-line lookahead made the scan quadratic. Every quote triggered a forward scan to end-of-line, and a run of `\'` defeats each one: the backslash is consumed as an escape pair inside the scan, so the quote after it never reads as a closing partner. The scan fails after walking the whole line, the main loop advances one character, and repeats. Measured before: 9KB 0.30s, 31KB 3.0s, 58KB 10.5s — 6x the input for 35x the time. doctor runs this over every installed module including third-party ones, so a minified or vendored stylesheet could stall CI. The fix is also a correctness improvement: CSS escapes apply outside strings too, and Tailwind depends on that (`.mt-\[773px\]`). Consuming the pair stops an escaped quote being read as a string opener at all. Now 390KB in 0.025s, and an escaped selector parses correctly where it previously did not. Splitting the tests: the file crossed the 300-line cap, so the scanner's tokenising rules (comments, strings, escapes, line numbers) move to test_css_scanner.py, leaving test_css_diagnostics.py to cover what SM022 and SM023 actually mean. Split by responsibility rather than squeezed under the cap, per CLAUDE.md. Claude-Session: https://claude.ai/code/session_01TmNYDBfPD3t5oQBzvysxVu
1 parent 33b6c1b commit 87f635a

21 files changed

Lines changed: 2397 additions & 38 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ docs/.vitepress/dist/
4545
host/client_app/modules.manifest.json
4646
host/client_app/modules.generated.ts
4747
host/client_app/modules.generated.css
48+
host/client_app/modules.assets.json
4849

4950
# Worktrees
5051
.worktrees/

‎CLAUDE.md‎

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

6976
**Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed:
7077
`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).
@@ -94,7 +101,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b
94101

95102
## Diagnostic codes
96103

97-
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.
104+
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.
98105

99106
## Tests & fixtures
100107

‎docs/module-authoring.md‎

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

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

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

270+
## Styling
271+
272+
A module may ship two optional stylesheets beside its `pages/` directory.
273+
Both are auto-detected exactly the way `pages/` is — there is no hook to
274+
override and nothing to register:
275+
276+
```
277+
my_module/
278+
├── module.py
279+
├── theme.css # optional — @theme tokens, @custom-variant, @font-face
280+
├── styles.css # optional — component rules, keyframes, vendor CSS
281+
└── pages/
282+
```
283+
284+
`smpy host gen-pages` emits an `@import` for each into
285+
`client_app/modules.generated.css`:
286+
287+
```css
288+
@import "#module/my_module/theme.css";
289+
@import "#module/my_module/styles.css" layer(components);
290+
```
291+
292+
**Nothing needs to be added to the host's `styles.css` by hand.** The
293+
`#module/<pkg>` specifier is a Vite alias built from `modules.assets.json`,
294+
so it resolves identically whether the module is a workspace member or
295+
installed from a wheel — and no generated file ends up containing a
296+
`../../../.venv/lib/python3.12/site-packages/...` path that would break the
297+
next time the interpreter version changes.
298+
299+
Imports are emitted in module discovery order, which is topological by
300+
`ModuleMeta.depends_on`. A module that depends on another can therefore
301+
override its dependency's styles.
302+
303+
### Which file does what
304+
305+
The split is not cosmetic — it is what makes the cascade rules structural
306+
rather than merely documented.
307+
308+
| | `theme.css` | `styles.css` |
309+
|---|---|---|
310+
| Imported | unlayered | `layer(components)` |
311+
| For | `@theme`, `@custom-variant`, `@utility`, `@font-face`, `:root` tokens | component rules, keyframes, vendor CSS |
312+
| Beats a Tailwind utility? | yes | no |
313+
314+
Tailwind v4 expands `@import "tailwindcss"` into
315+
`@layer theme, base, components, utilities`, and **unlayered CSS beats every
316+
layered rule**. So a module shipping a bare `.card { padding: 0 }` unlayered
317+
would silently override `p-4` on that element. But `@theme` blocks *must* be
318+
unlayered to register design tokens at all — a `@theme` inside a layer is
319+
inert. One file cannot satisfy both constraints, so each file gets one job.
320+
321+
`make doctor` catches the two ways to get this wrong: **SM022** flags
322+
`@theme`/`@custom-variant`/`@utility` sitting in `styles.css` (where they do
323+
nothing), and **SM023** flags an unlayered rule in `theme.css` (where it
324+
outranks every utility). Both are warnings — the CSS is legal either way, it
325+
just cascades in a way you probably did not intend.
326+
327+
### Cascade order
328+
329+
```
330+
design-system @theme < module theme.css < app @theme overrides
331+
```
332+
333+
A module normally *adds* tokens (`--color-map-water`); when it deliberately
334+
redefines a design-system token it wins, and the consuming app still has the
335+
final word from its own `@theme` block below the generated import.
336+
337+
### Packaging
338+
339+
**No packaging change is required.** The module wheel template already
340+
declares `[tool.hatch.build.targets.wheel] packages = ["my_module"]`, and
341+
Hatch includes every file under the package directory — `.css` along with
342+
`.tsx`. The `force-include` block is only needed for artifacts that live
343+
*outside* the package dir (`package.json`) or that are gitignored
344+
(`static/dist`).
345+
266346
## Templates
267347

268348
Jinja2 template directories contributed via `ModuleBase.template_dirs()`

0 commit comments

Comments
 (0)