Skip to content

feat: let modules ship CSS that reaches the host's Tailwind build - #238

Merged
antosubash merged 14 commits into
mainfrom
worktree-module-css-packaging
Aug 6, 2026
Merged

feat: let modules ship CSS that reaches the host's Tailwind build#238
antosubash merged 14 commits into
mainfrom
worktree-module-css-packaging

Conversation

@antosubash

@antosubash antosubash commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

A wheel-installed module could only contribute Tailwind @source class-scanning — there was no mechanism to ship real CSS. Apps worked around it by hand-editing the host stylesheet; smpy_gis carries:

@import "../../modules/gis/gis/static/tokens.css";

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:

File Imported as For
theme.css unlayered @theme tokens, @custom-variant, @font-face
styles.css layer(components) component rules, keyframes, vendor CSS

The split is load-bearing rather than stylistic: a @theme block 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 beat p-4. One file cannot satisfy both, so each file has exactly one job and the footgun becomes structurally impossible. SM022/SM023 catch authors who put a rule in the wrong one.

gen-pages emits 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 by depends_on), so a dependent module can override its dependency's styles.

Implementation notes

  • New simple_module_hosting/assets.pymanifest.py was already at the repo's 300-line cap.
  • New modules.assets.json is purely additive; modules.manifest.json keeps its exact {name: pages_dir} shape because vite.config.ts is 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.
  • The in-repo @source glob widens from pages/** to ** so classes used in module CSS are scanned.
  • All Vite/stylesheet changes are mirrored into the scaffold templates.

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.alias does govern CSS @import (the @tailwindcss/vite CSS resolver is built from {...config.resolve, …} in both code paths), and absolute @source paths do scan correctly.

test_module_css_build.py then drives a real npm run build and asserts .dashboard-stat-grid — declared only in modules/dashboard/dashboard/styles.css and referenced by no TSX anywhere — reaches the bundle. It can only get there via the alias. Confirmed from a fully cleaned dist/, and the rule lands inside @layer components with block order properties → theme → base → components → utilities.

make lint and make test are green (1538 passed, 2 skipped). make doctor reports no new diagnostics; its pre-existing SM020/SM003 findings are untouched by this branch.

Docs

docs/module-authoring.md § Styling, the module-layout tree and diagnostic list in CLAUDE.md, and the scaffold README template.

https://claude.ai/code/session_01RotaWUR7nhG5JwV59B1Znh

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

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

View logs

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
@antosubash

Copy link
Copy Markdown
Owner Author

Code review

Found 3 issues. All are fixed in 4cd528d.

  1. The SM022/SM023 scanner treated braces inside quoted strings as structural, so content: "{" desynced the depth counter for the rest of the file. Every later top-level construct then read as nested and was silently dropped — swallowing exactly the misplaced @theme that SM022 exists to catch. Verified: .icon { content: "{"; } followed by a top-level @theme returned zero diagnostics. The mirror case, a "}" in a string, closed a block early and produced a phantom SM023 on a stray quote character.

buf_line = 1
for ch in text:
if ch == "{":
if depth == 0:

  1. The scaffold template used existsSync + readFileSync for modules.assets.json, while the host copy of the same file used try/catch. Commit e955afd deliberately replaced that pattern (TOCTOU + double syscall) in host/client_app/vite.config.ts; new code should not reintroduce it. Both copies now match.

const assetsPath = path.resolve(__dirname, 'modules.assets.json');
if (fs.existsSync(assetsPath)) {
const assets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8')) as Record<string, ModuleAsset>;

  1. The alias-sort comment stated a rationale that isn't true. Vite matches a string find on exact equality or a /-bounded prefix (importee.startsWith(pattern + "/")), so #module/gis could never have shadowed #module/gis_extra in any order. The sort is kept for deterministic ordering and the comment now says that instead.

}
// Longest `find` first, so `#module/gis` cannot shadow `#module/gis_extra`.
moduleAliases.sort((a, b) => b.find.length - a.find.length);

Checked and found clean: CLAUDE.md compliance (300-line cap, dataclass over Pydantic, no framework/*modules/* import, SM022/SM023 continuing sequentially from SM021), asset ordering and dedup in assets.py, and the @source widening — no earlier commit had deliberately chosen the narrower pages/** form.

🤖 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
@antosubash

Copy link
Copy Markdown
Owner Author

Code review — convergence rounds 2-4

Ran the branch back through review until a full pass came back clean. Three further rounds, each finding a real defect in _top_level_preludes, all fixed. Round 4 found nothing.

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 url(data:image/svg+xml,...it's...) token. The pre-fix scanner handled both correctly by ignoring quotes entirely, so this was a regression. Fixed in 04df96a, then properly in a65f0cb — bounding strings at newlines was not enough, because a closing brace on the stray quote's own line was still eaten. A quote now opens a string only if its partner appears before end-of-line, which is the rule CSS itself applies.

Round 3 — the same-line lookahead was O(n²). A run of \' defeats each forward scan, so the scan walked the whole line, advanced one character, and repeated. Measured 9KB 0.30s, 31KB 3.0s, 58KB 10.5s. doctor runs this over every installed module including third-party ones, so a minified or vendored stylesheet could stall CI. Fixed in df75da8 by consuming CSS escape pairs outside strings — which is also more correct, since Tailwind selectors rely on escapes (.mt-\[773px\]). Now 390KB in 0.025s.

Also fixed: the widened @source glob matched any directory under modules/<name>/, so a module's sibling tests/ was scanned too. Confirmed on a real build that a class used only under tests/ reached the production bundle. Excluded via @source not (verified supported in the installed Tailwind), with a build check proving the package-dir class is still picked up, the tests-dir one is not, and module-shipped CSS still reaches the bundle.

Known limitation, documented in both styles.css files: a module's Python package must not itself be named tests, or that exclusion would skip the package too. No glob can distinguish a package directory from a sibling, and tests is not a viable package name regardless — it collides with pytest collection.

Verification on the final commit: 1545 Python tests pass, make ci-python-lint clean, 300-line cap satisfied (the scanner's tokenising tests were split into test_css_scanner.py by responsibility rather than squeezed under), make doctor reports no SM022/SM023 findings, and all 13 CI checks pass including E2E smoke and Perf guards.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@antosubash
antosubash merged commit 87f635a into main Aug 6, 2026
13 checks passed
antosubash added a commit that referenced this pull request Aug 9, 2026
)

* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant