Commit 87f635a
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_01TmNYDBfPD3t5oQBzvysxVu1 parent 33b6c1b commit 87f635a
21 files changed
Lines changed: 2397 additions & 38 deletions
File tree
- docs
- superpowers
- plans
- specs
- framework
- cli
- simple_module_cli/templates
- host/client_app
- module
- tests
- core
- simple_module_core/diagnostics
- tests
- hosting/simple_module_hosting
- host/client_app
- modules/dashboard/dashboard
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| 66 | + | |
| 67 | + | |
66 | 68 | | |
67 | 69 | | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
68 | 75 | | |
69 | 76 | | |
70 | 77 | | |
| |||
94 | 101 | | |
95 | 102 | | |
96 | 103 | | |
97 | | - | |
| 104 | + | |
98 | 105 | | |
99 | 106 | | |
100 | 107 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
246 | 250 | | |
247 | 251 | | |
248 | 252 | | |
| |||
263 | 267 | | |
264 | 268 | | |
265 | 269 | | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
266 | 346 | | |
267 | 347 | | |
268 | 348 | | |
| |||
0 commit comments