feat: let modules ship CSS that reaches the host's Tailwind build - #238
Conversation
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
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
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
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
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
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
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
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
Deploying simple-module-python with
|
| Latest commit: |
df75da8
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8a88873d.simple-module-python.pages.dev |
| Branch Preview URL: | https://worktree-module-css-packagin.simple-module-python.pages.dev |
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
Code reviewFound 3 issues. All are fixed in 4cd528d.
simple_module_python/host/client_app/vite.config.ts Lines 80 to 82 in de45e03 Checked and found clean: CLAUDE.md compliance (300-line cap, dataclass over Pydantic, no 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
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
…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
… 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
…ner 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
Code review — convergence rounds 2-4Ran the branch back through review until a full pass came back clean. Three further rounds, each finding a real defect in Round 2 — unmatched quote silently disabled the lint. The string handling 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. Triggered by an ordinary typo, and by the lone apostrophe in an unquoted Round 3 — the same-line lookahead was O(n²). A run of Also fixed: the widened Known limitation, documented in both Verification on the final commit: 1545 Python tests pass, 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
) * docs: sync docs with recent changes (branding white-label, SM022/SM023, Docker scaffold) The docs had drifted behind several merged features: - modules/branding: rewritten. The page still described the pre-#237 module (name, logo, favicon, colour). Adds the dark-background logo variant, design pack, announcement banner, configurable footer + its limits and href allow-list, presets, the anonymous asset routes and their cache policy, and the magic-number upload check. Corrects the allowed-types list, which still advertised image/svg+xml — SVG is excluded on purpose (XML that can carry <script>, i.e. stored XSS from our own origin). - reference/diagnostic-codes: add the missing SM022/SM023 rows. Every other code in the framework was documented; these two shipped with #238. - framework/discovery: document i18n_audience and requires_framework in the ModuleMeta section, cross-linked to the i18n page. - reference/deployment: the Build section told operators to hand-roll a Dockerfile that every scaffold has shipped since #252 — and the example used a Node-only frontend stage, which cannot work, since gen-pages reads the installed Python modules. Replaced with the shipped assets plus why the single builder stage is load-bearing. - alembic upgrade head -> heads across guide/reference. Every real command (Makefile, smpy new, the container CMD) uses the plural; the singular errors once a second module ships a branch label. - Module count eleven -> twelve; branding was missing from the home page list, and its index row predated the white-label work. Verified: vitepress build clean, and the four cross-page anchors checked against the generated HTML (ignoreDeadLinks is on, so the build itself does not catch them). Claude-Session: https://claude.ai/code/session_013W1MJ3T4FJEBcx1Xs9Tea2 * docs: fix accuracy issues found in review - branding: the magic-number check was overstated. validate_image matches the head against *any* allowed signature, not the one the declared Content-Type implies, so a genuine PNG sent as image/jpeg passes and is stored as image/jpeg. Reworded to the property that actually holds (non-images are kept out) rather than type/content agreement. - branding: the file_storage download permission is `file_storage.download`, not `file-storage.download`. Copied the hyphen from the stale comment at branding/constants.py:76; the constant is FileStoragePermissions.DOWNLOAD. - framework/overview: module count said "ten first-party modules" — missed in the eleven -> twelve sweep, so two pages one click apart disagreed. - module-authoring: one more `alembic upgrade head` -> `heads`, in the publish-a-module flow whose very next subsection tells the author to add branch_labels — i.e. exactly the case the plural exists for. - deployment: the new "run make docker-up" Build section sat three lines under a checklist item requiring Postgres, while the default SQLite compose pins SM_ENVIRONMENT=production *and* a SQLite URL. Added a warning that the SQLite stack is local/demo only, and why it isn't a one-env-var swap (migration histories are dialect-frozen). Also fixes the scaffold README template, which shipped `upgrade head` into every generated project while that project's own Makefile and Dockerfile use `heads`. test_cli_new_scaffold_layout already asserts the singular form is gone from the Makefile, so the README was simply missed. Verified: 216 CLI tests pass, vitepress build clean. Claude-Session: https://claude.ai/code/session_013W1MJ3T4FJEBcx1Xs9Tea2
Problem
A wheel-installed module could only contribute Tailwind
@sourceclass-scanning — there was no mechanism to ship real CSS. Apps worked around it by hand-editing the host stylesheet;smpy_giscarries:which breaks entirely once the module is pip-installed, is never regenerated by
gen-pages, and has no defined ordering against Tailwind's layers.What this does
A module may now ship two optional stylesheets beside
pages/, auto-detected exactly like pages are — no hook, no config:theme.css@themetokens,@custom-variant,@font-facestyles.csslayer(components)The split is load-bearing rather than stylistic: a
@themeblock inside a cascade layer is inert and registers no tokens, while unlayered CSS outranks every Tailwind utility — a module shipping a bare.card { padding: 0 }would silently beatp-4. One file cannot satisfy both, so each file has exactly one job and the footgun becomes structurally impossible.SM022/SM023catch authors who put a rule in the wrong one.gen-pagesemits the imports through a per-module Vite alias (#module/<pkg>/styles.css), so generated CSS never contains../../../.venv/lib/python3.12/site-packages/<pkg>/styles.css. Emission follows module discovery order (topological bydepends_on), so a dependent module can override its dependency's styles.Implementation notes
simple_module_hosting/assets.py—manifest.pywas already at the repo's 300-line cap.modules.assets.jsonis purely additive;modules.manifest.jsonkeeps its exact{name: pages_dir}shape becausevite.config.tsis scaffolded into every downstream app (smpy_gis,smpy_saas,laco_wiki_python,smpy_pagebuilder) and changing the value shape would break them all at once. The new file also covers CSS-only modules, which never appear in the pages manifest.@sourceglob widens frompages/**to**so classes used in module CSS are scanned.Verification
The design rested on two unverified resolver claims, so both were settled with a throwaway Tailwind 4.2.4 spike before any production code:
resolve.aliasdoes govern CSS@import(the@tailwindcss/viteCSS resolver is built from{...config.resolve, …}in both code paths), and absolute@sourcepaths do scan correctly.test_module_css_build.pythen drives a realnpm run buildand asserts.dashboard-stat-grid— declared only inmodules/dashboard/dashboard/styles.cssand referenced by no TSX anywhere — reaches the bundle. It can only get there via the alias. Confirmed from a fully cleaneddist/, and the rule lands inside@layer componentswith block orderproperties → theme → base → components → utilities.make lintandmake testare green (1538 passed, 2 skipped).make doctorreports no new diagnostics; its pre-existingSM020/SM003findings are untouched by this branch.Docs
docs/module-authoring.md§ Styling, the module-layout tree and diagnostic list inCLAUDE.md, and the scaffold README template.https://claude.ai/code/session_01RotaWUR7nhG5JwV59B1Znh