From 56bc575ced47fb13b947dd57dbcc8ad40dda1227 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 5 Aug 2026 21:13:19 +0200 Subject: [PATCH 01/14] 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 --- .../2026-08-05-module-css-packaging-design.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-module-css-packaging-design.md diff --git a/docs/superpowers/specs/2026-08-05-module-css-packaging-design.md b/docs/superpowers/specs/2026-08-05-module-css-packaging-design.md new file mode 100644 index 00000000..d4b95b41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-module-css-packaging-design.md @@ -0,0 +1,285 @@ +# Module CSS after packaging — design + +**Date:** 2026-08-05 +**Status:** Approved, ready for implementation planning + +## Problem + +A module packaged as a wheel cannot contribute CSS to the host's Tailwind build. + +The only thing `smpy gen-pages` emits today for a wheel-installed module is a +`@source` line in `host/client_app/modules.generated.css`, which drives Tailwind +*class scanning* and nothing else. There is no mechanism for a module to ship +actual CSS — design tokens, component classes, keyframes, vendor stylesheets. + +This is not hypothetical. In `smpy_gis` the app had to hand-edit +`host/client_app/styles.css` with: + +```css +@import "../../modules/gis/gis/static/tokens.css"; +``` + +That line: + +- only resolves for an **in-repo** module; it breaks entirely once the module is + pip-installed into `.venv/.../site-packages/`, +- is **not regenerated** by `smpy gen-pages`, so installing a module means + remembering to hand-edit host CSS, +- has **no defined ordering** against other modules' CSS or against the design + system's own `@theme`. + +Four distinct pain points were confirmed as in scope: modules can't ship CSS, +`@source` handling is fragile, cascade/ordering is undefined, and dev/HMR +ergonomics are poor. + +## Non-goals + +- Runtime CSS injection or per-request stylesheet composition. +- Automatic scoping or class-name prefixing of module CSS. +- Enabling/disabling a module's CSS at runtime. +- Scaffolding empty `theme.css` / `styles.css` files from `make new-module`. + Files that are always imported but usually empty are noise; the convention is + documented instead. + +## Design + +### 1. Authoring surface — convention, zero config + +``` +modules/// +├── module.py +├── theme.css # optional — @theme tokens, @custom-variant, @font-face +├── styles.css # optional — component rules, keyframes, vendor CSS +└── pages/ +``` + +Both files are optional and auto-detected, exactly the way `pages/` is +auto-detected today by `compute_module_pages()`. No new `ModuleBase` hook, no +`module.py` change. + +**No packaging change is required.** The module wheel template already declares +`[tool.hatch.build.targets.wheel] packages = [""]`, and Hatch includes every +file under the package directory — `.tsx` and `.css` included. The existing +`force-include` block is only needed for artifacts that live *outside* the +package dir (`package.json`) or are gitignored (`static/dist`). + +Splitting into two files rather than one is what makes the cascade rules in §3 +structurally enforceable rather than merely documented. + +### 2. Emission — `modules.generated.css` + +`write_module_pages_manifest` gains a third responsibility and emits three +ordered sections: + +```css +/* AUTO-GENERATED by simple_module_hosting.manifest — do not edit by hand. */ + +/* ── @source: class scanning (wheel-installed modules only) ── */ +@source "/abs/path/to/site-packages/gis/**/*.{ts,tsx}"; + +/* ── theme: unlayered ── */ +@import "#module/gis/theme.css"; + +/* ── styles: layer(components) ── */ +@import "#module/gis/styles.css" layer(components); +``` + +Three decisions here: + +**Import order is module discovery order, not alphabetical.** +`discover_modules()` already returns modules topologically sorted by +`ModuleMeta.depends_on`, which is the same order the host invokes `register_*` +hooks in. Emitting CSS in that order means a module that depends on another can +override it, matching the ordering contract module authors already reason about. +The current generator does `for name in sorted(pages_map)`. + +**`@import` is emitted for every module; `@source` only for wheel-installed +ones.** This asymmetry is deliberate. In-repo module *pages* are already covered +by the static `@source "../../modules/*/*/pages/**/*.{ts,tsx}"` glob in +`host/client_app/styles.css`, so emitting an absolute `@source` for them would +duplicate it — that is what `_is_in_repo_module()` exists to prevent. There is no +static-glob equivalent for CSS, so the `_is_in_repo_module` skip must stay +confined to the `@source` section. + +**`@source` keeps absolute paths; `@import` uses an alias.** These go through +two different resolvers. `@source` is scanned by Tailwind's own machinery, where +an absolute path is fine. `@import` goes through Vite's resolver — see §4. + +### 3. Cascade + +Host `styles.css` becomes: + +```css +@import "../../packages/ui/src/styles/globals.css"; /* tailwindcss + DS @theme */ +@import "./modules.generated.css"; /* module theme + components */ +/* app-level @theme overrides go here — last word */ + +@source "./**/*.{ts,tsx}"; +@source "../../modules/*/*/**/*.{ts,tsx}"; /* widened from pages/** */ +``` + +Resulting token precedence: **design-system theme < module theme < app +overrides.** A module normally *adds* tokens (`--color-map-water`); when it +deliberately redefines a design-system token it wins, and the app still has the +final word via its own `@theme` block below the generated import. + +Module component rules land in `layer(components)` and therefore always lose to +Tailwind utilities. + +This is the whole reason for the two-file split. Tailwind v4 expands +`@import "tailwindcss"` into `@layer theme, base, components, utilities`, and +**unlayered CSS beats every layered rule**. A module shipping a bare +`.card { padding: 0 }` would silently override `p-4` on that element. But +`@theme` blocks *must* be unlayered to register design tokens, and a `@theme` +inside a layered import is inert. One file cannot satisfy both constraints; +two files give each one job and make the footgun impossible rather than +merely discouraged. + +**Module imports must stay after `@import "tailwindcss"`.** This is a +correctness constraint, not a style preference. Layer precedence is fixed by +the order layers are first declared. A `layer(components)` import appearing +before Tailwind's `@layer theme, base, components, utilities;` declaration +would register `components` as the *first* layer, dropping module component +rules below preflight. + +The in-repo `@source` glob widens from `pages/**` to `**` so `.ts`/`.tsx` living +outside `pages/` is scanned too. The `{ts,tsx}` extension filter keeps `.py` +files out of the scan. + +### 4. Alias resolution and the Vite manifest + +Module CSS is referenced through a per-module Vite alias, so no generated file +ever contains a path like +`../../../.venv/lib/python3.12/site-packages/gis/styles.css`. + +```ts +// vite.config.ts — built from modules.assets.json +resolve: { + alias: moduleAliases, // longest `find` first +} +``` + +**This is verified, not assumed.** `@tailwindcss/vite` 4.2.4 builds its CSS +import resolver as: + +```js +createResolver({ ...config.resolve, extensions: ['.css'], mainFields: ['style'], + conditions: ['style', 'development|production'], + tryIndex: false, preferRelative: true }) +``` + +in both of its code paths. The spread of `...config.resolve` carries `alias` +through, so `resolve.alias` governs CSS `@import`. The resolver additionally +only accepts a result that is absolute and ends in `.css`, which an +alias→directory replacement satisfies. + +Aliases must be sorted **longest `find` first** so that `#module/gis` does not +shadow `#module/gis-extra`. + +**Alias casing.** The alias segment is the module's **lowercase Python package +name** (`gis`, `background_tasks`), not `ModuleMeta.name`. `modules.manifest.json` +is keyed by `mod.meta.name`, which is PascalCase for Inertia page resolution +(`BlogPosts`); reusing that in a CSS specifier would read badly and invites +case-sensitivity bugs across filesystems. `modules.assets.json` therefore carries +the package name as an explicit field rather than leaving callers to derive it: + +```jsonc +{ + "BlogPosts": { + "package_name": "blog_posts", // -> alias "#module/blog_posts" + "package": "/abs/.../blog_posts", + "pages": "/abs/.../blog_posts/pages", + "theme": null, + "styles": "/abs/.../blog_posts/styles.css" + } +} +``` + +The top-level key stays `mod.meta.name` for consistency with the existing +manifest. + +**Manifest compatibility.** `modules.manifest.json` maps module name → *pages* +directory, and `vite.config.ts` derives the package dir as +`path.dirname(pagesDir)` for `server.fs.allow`. A CSS-only module with no +`pages/` never enters that manifest, so it would be missing from both the alias +list and `fs.allow`. + +Rather than change the manifest's value shape — `vite.config.ts` is scaffolded +*into* each app, so `smpy_gis`, `smpy_saas`, `laco_wiki_python` and +`smpy_pagebuilder` each hold their own copy and would all break at once — add a +new **additive** file: + +```jsonc +// modules.assets.json +{ + "Gis": { + "package": "/abs/.../gis", + "pages": "/abs/.../gis/pages", // or null + "theme": "/abs/.../gis/theme.css", // or null + "styles": "/abs/.../gis/styles.css" // or null + } +} +``` + +`modules.manifest.json` keeps its exact current shape and semantics. Existing +apps keep building untouched; they opt into module CSS by updating +`vite.config.ts`, which they must do anyway to gain the aliases. + +### 5. Doctor diagnostics + +Two new warnings. Both codes are free — the existing set stops at `SM021`. + +- **SM022** (warn) — `styles.css` contains a top-level `@theme`, + `@custom-variant`, or `@utility`. These are inert inside `layer(components)`; + move them to `theme.css`. +- **SM023** (warn) — `theme.css` contains a top-level rule whose selector is + anything other than `:root`. Unlayered rules beat every Tailwind utility; + move them to `styles.css`. + +These are warnings rather than errors: both describe CSS that is legal and will +build, just miscategorised in a way that produces surprising cascade behaviour. + +**Detection is a line-oriented scan of top-level at-rules and selectors, not a +CSS parse.** No CSS parser is added as a Python dependency for a lint. The check +tracks brace depth to distinguish top-level constructs from nested ones, strips +`/* */` comments first, and reports the offending line number. This misses +pathological input (an at-rule assembled by preprocessing, say) and that is +acceptable — the diagnostic exists to catch the ordinary mistake of putting +`@theme` in the wrong file, not to be a conformance checker. + +### 6. Tests + +Extend `framework/cli/tests/test_module_pages_manifest.py`: + +- `theme.css` / `styles.css` detection, including modules that ship one, both, + or neither. +- Alias specifiers are emitted (no `../` sequences anywhere in the generated + CSS). +- Emission follows discovery order, not alphabetical order. +- The asymmetry: `@import` for in-repo modules, no `@source` for them. +- A CSS-only module (no `pages/`) still appears in `modules.assets.json`. + +Doctor tests covering SM022 and SM023, positive and negative. + +One build-level assertion that a class used only in a module-shipped stylesheet +survives `npm run build` — the check that would actually catch this class of +regression, since every unit test above can pass while Tailwind still emits +nothing. + +### 7. Docs + +- `docs/module-authoring.md` — new "Styling" section covering the two-file + convention, the cascade rules, and why `@theme` belongs in `theme.css`. +- `CLAUDE.md` — note the convention alongside the existing module-layout tree, + and add SM022/SM023 to the diagnostic-code list. + +## To verify during implementation + +Two claims are load-bearing and should be tested rather than trusted, given one +resolver claim was already wrong once during design: + +1. `@source` with an absolute path genuinely works in Tailwind 4.2.4. If it does + not, the `@source` section switches to the same alias treatment as `@import`. +2. Whether `server.fs.allow` matters for CSS at all — `@import` is inlined at + transform time rather than served as a URL, so it may only affect HMR + watching of files outside the project root. From 451d15d1a95ad13ced5588406f1e51caa6843c0d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 5 Aug 2026 21:17:44 +0200 Subject: [PATCH 02/14] docs(plan): implementation plan for module CSS packaging --- .../plans/2026-08-05-module-css-packaging.md | 811 ++++++++++++++++++ 1 file changed, 811 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-module-css-packaging.md diff --git a/docs/superpowers/plans/2026-08-05-module-css-packaging.md b/docs/superpowers/plans/2026-08-05-module-css-packaging.md new file mode 100644 index 00000000..d22b3bd5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-module-css-packaging.md @@ -0,0 +1,811 @@ +# Module CSS Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a pip-installed module ship real CSS (design tokens and component +styles) that lands in the host's Tailwind build automatically, with a defined +cascade position and no hand-edited paths. + +**Architecture:** A module may ship `/theme.css` and `/styles.css`, +auto-detected like `pages/`. `smpy gen-pages` emits `@import` lines into +`host/client_app/modules.generated.css` — `theme.css` unlayered (so `@theme` +tokens register) and `styles.css` as `layer(components)` (so module rules can +never beat a utility). Paths are per-module Vite aliases (`#module/`) fed +by a new additive `modules.assets.json`, so no generated file contains a +`../../../.venv/...` path. + +**Tech Stack:** Python 3.12, FastAPI, Hatch wheels, Vite 8, `@tailwindcss/vite` +4.2.4, Tailwind CSS v4, pytest. + +## Global Constraints + +- **300-line cap** on `.py`/`.ts`/`.tsx`, enforced by `scripts/check_file_size.py`. + `manifest.py` is already at 275 lines, so new asset logic goes in a new + `framework/hosting/simple_module_hosting/assets.py`. +- **SQLModel is the project-wide model standard.** Not relevant here (no models), + but do not introduce Pydantic `BaseModel` for the asset record — use a + `dataclass`. +- **No new Python dependencies.** In particular, no CSS parser for the SM022 / + SM023 lints. +- **`modules.manifest.json` keeps its exact current shape** (`{name: pages_dir}`). + Downstream apps (`smpy_gis`, `smpy_saas`, `laco_wiki_python`, + `smpy_pagebuilder`) hold their own copy of `vite.config.ts` and must keep + building untouched. +- **Alias segment is the lowercase Python package name** (`#module/blog_posts`), + not `ModuleMeta.name` (`BlogPosts`). +- Diagnostic codes: **SM022**, **SM023**. Existing set stops at SM021. +- Run `make ci-python-lint` rather than `make lint` — `make lint` fails + repo-wide on a pre-existing invalid `preset` key in `biome.json`. + +--- + +### Task 1: Verify the two load-bearing resolver claims + +The spec flags two assumptions as untested. Both change the design if wrong, so +they are checked before any code is written. + +**Files:** +- Create (throwaway): `$CLAUDE_JOB_DIR/tmp/css-spike/` + +- [ ] **Step 1: Build a minimal Tailwind 4.2.4 spike** + +Create a scratch Vite project that imports a CSS file from outside its root via +a `resolve.alias`, and `@source`s a directory by absolute path. Put a class in +the aliased CSS and a utility class in a `.tsx` under the absolute `@source` +dir. + +- [ ] **Step 2: Run the build and inspect output CSS** + +Confirm two things independently: +1. The aliased `@import` resolved (the module CSS rule appears in output). +2. The absolute `@source` scanned (the utility from that dir appears in output). + +- [ ] **Step 3: Record the result** + +If the absolute `@source` fails, the `@source` section switches to alias +specifiers too, and Task 3's emission changes accordingly. If the alias +`@import` fails, stop — the design needs revisiting. + +- [ ] **Step 4: Note whether `fs.allow` was needed** + +Run the dev server against a file outside the root without `fs.allow` and see +whether the CSS still resolves. `@import` is inlined at transform time, so it +may not need whitelisting. + +--- + +### Task 2: Asset discovery — `compute_module_assets` + +**Files:** +- Create: `framework/hosting/simple_module_hosting/assets.py` +- Test: `framework/cli/tests/test_module_css_assets.py` + +**Interfaces:** +- Consumes: `simple_module_core.get_module_package_name`, `ModuleBase`. +- Produces: + - `@dataclass(frozen=True) ModuleAssets` with fields + `name: str`, `package_name: str`, `package_dir: Path`, + `pages_dir: Path | None`, `theme_css: Path | None`, `styles_css: Path | None`. + - `compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]` + — preserves input (discovery) order; includes a module if it has **any** of + pages/theme/styles. + +- [ ] **Step 1: Write the failing test** + +```python +"""Tests for module CSS asset discovery and emission.""" + +from __future__ import annotations + + +class TestComputeModuleAssets: + async def test_detects_theme_and_styles(self, tmp_path, monkeypatch): + """A module shipping theme.css/styles.css has them detected.""" + import sys + + from simple_module_core import ModuleBase, ModuleMeta + from simple_module_hosting.assets import compute_module_assets + + pkg = tmp_path / "styled_mod" + (pkg / "pages").mkdir(parents=True) + (pkg / "theme.css").write_text("@theme { --color-x: red; }\n") + (pkg / "styles.css").write_text(".x { color: red; }\n") + (pkg / "__init__.py").write_text("") + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop("styled_mod", None) + + module_src = "from simple_module_core import ModuleBase, ModuleMeta\n" + (pkg / "module.py").write_text(module_src) + + class StyledMod(ModuleBase): + meta = ModuleMeta(name="Styled") + + # Force the package association the helper resolves against. + StyledMod.__module__ = "styled_mod.module" + + result = compute_module_assets([StyledMod()]) + assert len(result) == 1 + entry = result[0] + assert entry.package_name == "styled_mod" + assert entry.theme_css is not None and entry.theme_css.name == "theme.css" + assert entry.styles_css is not None and entry.styles_css.name == "styles.css" + assert entry.pages_dir is not None + + async def test_css_only_module_is_included(self, tmp_path, monkeypatch): + """A module with CSS but no pages/ still appears (the manifest gap).""" + import sys + + from simple_module_core import ModuleBase, ModuleMeta + from simple_module_hosting.assets import compute_module_assets + + pkg = tmp_path / "cssonly_mod" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("") + (pkg / "styles.css").write_text(".y { color: blue; }\n") + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop("cssonly_mod", None) + + class CssOnly(ModuleBase): + meta = ModuleMeta(name="CssOnly") + + CssOnly.__module__ = "cssonly_mod.module" + + result = compute_module_assets([CssOnly()]) + assert [e.name for e in result] == ["CssOnly"] + assert result[0].pages_dir is None + assert result[0].styles_css is not None + + async def test_preserves_discovery_order(self): + """Order follows discover_modules(), not alphabetical.""" + from simple_module_core import discover_modules + from simple_module_hosting.assets import compute_module_assets + + modules = discover_modules() + result = compute_module_assets(modules) + names = [e.name for e in result] + assert names != sorted(names) or len(names) <= 1 + discovery_order = [m.meta.name for m in modules] + assert names == [n for n in discovery_order if n in set(names)] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest framework/cli/tests/test_module_css_assets.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'simple_module_hosting.assets'` + +- [ ] **Step 3: Write the implementation** + +```python +"""Per-module frontend asset discovery (pages + CSS). + +Separate from ``manifest.py`` because that file is already at the repo's +300-line cap. ``manifest.py`` imports from here. +""" + +from __future__ import annotations + +import importlib.resources +import logging +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +from simple_module_core import ModuleBase, get_module_package_name + +logger = logging.getLogger(__name__) + +THEME_CSS = "theme.css" +STYLES_CSS = "styles.css" + + +@dataclass(frozen=True) +class ModuleAssets: + """Frontend assets a single module contributes.""" + + name: str + package_name: str + package_dir: Path + pages_dir: Path | None + theme_css: Path | None + styles_css: Path | None + + +def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: + """Return per-module frontend assets, preserving discovery order. + + Discovery order matters: ``discover_modules()`` topologically sorts by + ``depends_on``, so emitting CSS in this order lets a dependent module + override its dependency. A module is included only if it contributes at + least one asset. + """ + result: list[ModuleAssets] = [] + for mod in modules: + pkg_name = get_module_package_name(mod) + try: + pkg_root = Path(str(importlib.resources.files(pkg_name))) + except ModuleNotFoundError: + logger.debug( + "Module '%s': package %s not importable — skipping", mod.meta.name, pkg_name + ) + continue + pages_dir = pkg_root / "pages" + theme = pkg_root / THEME_CSS + styles = pkg_root / STYLES_CSS + entry = ModuleAssets( + name=mod.meta.name, + package_name=pkg_name, + package_dir=pkg_root.resolve(), + pages_dir=pages_dir.resolve() if pages_dir.is_dir() else None, + theme_css=theme.resolve() if theme.is_file() else None, + styles_css=styles.resolve() if styles.is_file() else None, + ) + if entry.pages_dir or entry.theme_css or entry.styles_css: + result.append(entry) + return result +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest framework/cli/tests/test_module_css_assets.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/assets.py framework/cli/tests/test_module_css_assets.py +git commit -m "feat(hosting): discover per-module theme.css/styles.css assets" +``` + +--- + +### Task 3: Emit CSS imports and `modules.assets.json` + +**Files:** +- Modify: `framework/hosting/simple_module_hosting/assets.py` (add emitters) +- Modify: `framework/hosting/simple_module_hosting/manifest.py` (CSS section + assets file) +- Test: `framework/cli/tests/test_module_css_assets.py` + +**Interfaces:** +- Consumes: `ModuleAssets`, `compute_module_assets` from Task 2. +- Produces: + - `ALIAS_PREFIX = "#module"` + - `render_modules_css(assets, *, in_repo: Callable[[Path], bool]) -> str` + - `render_assets_json(assets) -> str` + - `write_module_pages_manifest` returns an extra `"assets"` key. + +- [ ] **Step 1: Write the failing test** + +```python +class TestCssEmission: + async def test_emits_alias_imports_with_no_relative_paths(self, tmp_path): + """Generated CSS references modules by alias, never by ../.. path.""" + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + modules = discover_modules() + written = write_module_pages_manifest(modules, tmp_path) + css = written["css"].read_text(encoding="utf-8") + + import_lines = [ln for ln in css.splitlines() if ln.startswith("@import")] + for line in import_lines: + assert "../" not in line, f"generated @import must not use relative paths: {line}" + assert '"#module/' in line, f"@import must use the alias prefix: {line}" + + async def test_styles_layered_theme_unlayered(self, tmp_path): + """theme.css imports unlayered; styles.css imports into layer(components).""" + from simple_module_hosting.assets import ModuleAssets, render_modules_css + + assets = [ + ModuleAssets( + name="Gis", + package_name="gis", + package_dir=tmp_path / "gis", + pages_dir=None, + theme_css=tmp_path / "gis" / "theme.css", + styles_css=tmp_path / "gis" / "styles.css", + ) + ] + css = render_modules_css(assets, in_repo=lambda _p: False) + + assert '@import "#module/gis/theme.css";' in css + assert '@import "#module/gis/styles.css" layer(components);' in css + # theme must precede styles so tokens exist before rules consume them + assert css.index("theme.css") < css.index("styles.css") + + async def test_source_skips_in_repo_but_import_does_not(self, tmp_path): + """@source is wheel-only; @import is emitted for every module.""" + from simple_module_hosting.assets import ModuleAssets, render_modules_css + + assets = [ + ModuleAssets( + name="Local", + package_name="local", + package_dir=tmp_path / "local", + pages_dir=tmp_path / "local" / "pages", + theme_css=None, + styles_css=tmp_path / "local" / "styles.css", + ) + ] + css = render_modules_css(assets, in_repo=lambda _p: True) + + assert "@source" not in css, "in-repo pages are covered by the static glob" + assert '@import "#module/local/styles.css" layer(components);' in css + + async def test_writes_assets_json(self, tmp_path): + """modules.assets.json is emitted alongside the existing three files.""" + import json + + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + written = write_module_pages_manifest(discover_modules(), tmp_path) + assets_path = tmp_path / "modules.assets.json" + assert assets_path.is_file() + assert written["assets"] == assets_path + + data = json.loads(assets_path.read_text(encoding="utf-8")) + entry = data["Dashboard"] + assert entry["package_name"] == "dashboard" + assert entry["package"].endswith("dashboard") + assert set(entry) == {"package_name", "package", "pages", "theme", "styles"} + + async def test_manifest_json_shape_unchanged(self, tmp_path): + """modules.manifest.json stays {name: pages_dir} for downstream vite configs.""" + import json + + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + write_module_pages_manifest(discover_modules(), tmp_path) + data = json.loads((tmp_path / "modules.manifest.json").read_text(encoding="utf-8")) + assert all(isinstance(v, str) for v in data.values()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest framework/cli/tests/test_module_css_assets.py -v` +Expected: FAIL — `render_modules_css` / `render_assets_json` undefined, and +`written` has no `"assets"` key. + +- [ ] **Step 3: Implement the emitters in `assets.py`** + +```python +ALIAS_PREFIX = "#module" + +_CSS_HEADER = """\ +/* AUTO-GENERATED by simple_module_hosting.assets — do not edit by hand. + * Regenerate with: smpy gen-pages + * + * Emission order is module discovery order (topological by depends_on), + * so a dependent module's CSS can override its dependency's. + */ +""" + + +def render_modules_css( + assets: Sequence[ModuleAssets], + *, + in_repo: Callable[[Path], bool], +) -> str: + """Render ``modules.generated.css``. + + Three sections. ``@source`` is emitted only for wheel-installed modules + (in-repo pages are already covered by the static glob in the host's + styles.css), but ``@import`` is emitted for every module — there is no + static-glob equivalent for CSS. + + ``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. + """ + source_lines = [ + f'@source "{e.pages_dir.as_posix()}/**/*.{{ts,tsx}}";' + for e in assets + if e.pages_dir and not in_repo(e.pages_dir) + ] + theme_lines = [ + f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' + for e in assets + if e.theme_css + ] + style_lines = [ + f'@import "{ALIAS_PREFIX}/{e.package_name}/{STYLES_CSS}" layer(components);' + for e in assets + if e.styles_css + ] + + out = [_CSS_HEADER] + for heading, lines in ( + ("/* ── @source: class scanning (wheel-installed modules) ── */", source_lines), + ("/* ── theme: unlayered, registers @theme tokens ── */", theme_lines), + ("/* ── styles: layer(components), always loses to utilities ── */", style_lines), + ): + if lines: + out.append("") + out.append(heading) + out.extend(lines) + out.append("") + return "\n".join(out) + + +def render_assets_json(assets: Sequence[ModuleAssets]) -> str: + """Render ``modules.assets.json`` — the richer companion to the pages manifest.""" + payload = { + e.name: { + "package_name": e.package_name, + "package": e.package_dir.as_posix(), + "pages": e.pages_dir.as_posix() if e.pages_dir else None, + "theme": e.theme_css.as_posix() if e.theme_css else None, + "styles": e.styles_css.as_posix() if e.styles_css else None, + } + for e in assets + } + return json.dumps(payload, indent=2, sort_keys=True) + "\n" +``` + +Add `import json` and `from collections.abc import Callable, Sequence` at the +top of `assets.py`. + +- [ ] **Step 4: Rewire `manifest.py`** + +Replace the CSS-emission block in `write_module_pages_manifest` with calls to +the new renderers, add the `modules.assets.json` write, and include `"assets"` +in the returned dict. Delete the now-unused `_GENERATED_CSS_HEADER`. Keep +`_is_in_repo_module` and pass it as the `in_repo` callable: + +```python + assets = compute_module_assets(modules) + css_text = render_modules_css( + assets, in_repo=lambda p: _is_in_repo_module(p, repo_root) + ) + assets_path = output_dir / "modules.assets.json" + wrote_assets = _write_if_changed(assets_path, render_assets_json(assets)) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest framework/cli/tests/test_module_css_assets.py framework/cli/tests/test_module_pages_manifest.py -v` +Expected: PASS (both files — the existing manifest tests must not regress) + +- [ ] **Step 6: Check the file-size cap** + +Run: `uv run python scripts/check_file_size.py` +Expected: PASS — `manifest.py` must still be under 300 lines. + +- [ ] **Step 7: Commit** + +```bash +git add framework/hosting/simple_module_hosting/ framework/cli/tests/test_module_css_assets.py +git commit -m "feat(hosting): emit aliased module CSS imports and modules.assets.json" +``` + +--- + +### Task 4: Wire Vite aliases and widen the host `@source` glob + +**Files:** +- Modify: `host/client_app/vite.config.ts` +- Modify: `host/client_app/styles.css` +- Modify: `framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts` +- Modify: `framework/cli/simple_module_cli/templates/host/client_app/styles.css` + +**Interfaces:** +- Consumes: `modules.assets.json` from Task 3. +- Produces: a `#module/` alias per module, resolvable from CSS + and TSX alike. + +- [ ] **Step 1: Read `modules.assets.json` in `vite.config.ts`** + +Add alongside the existing manifest read, leaving that read intact: + +```ts +// Aliases let generated CSS reference module files as +// "#module//styles.css" instead of a brittle +// ../../../.venv/lib/python3.12/site-packages//styles.css path. +// @tailwindcss/vite resolves CSS @import through Vite's resolver +// (createResolver({...config.resolve, ...})), so resolve.alias applies. +type ModuleAsset = { package_name: string; package: string }; +const moduleAliases: { find: string; replacement: string }[] = []; +const assetsPath = path.resolve(__dirname, 'modules.assets.json'); +if (fs.existsSync(assetsPath)) { + const assets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8')) as Record; + for (const entry of Object.values(assets)) { + moduleAliases.push({ + find: `#module/${entry.package_name}`, + replacement: entry.package, + }); + if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package); + } + // Longest find first so "#module/gis" can't shadow "#module/gis_extra". + moduleAliases.sort((a, b) => b.find.length - a.find.length); +} +``` + +- [ ] **Step 2: Register the aliases** + +In the existing `resolve:` block, add `alias: moduleAliases,` beside +`tsconfigPaths` and `dedupe`. + +- [ ] **Step 3: Widen the in-repo `@source` glob** + +In both `styles.css` files, change +`@source "../../modules/*/*/pages/**/*.{ts,tsx}";` to +`@source "../../modules/*/*/**/*.{ts,tsx}";` so `.ts`/`.tsx` outside `pages/` +is scanned. The `{ts,tsx}` filter keeps `.py` out. + +- [ ] **Step 4: Mirror steps 1-2 into the scaffold template** + +Apply the same edits to +`framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts` so +new `smpy new` apps get aliases from the start. + +- [ ] **Step 5: Verify the templates still typecheck and the app builds** + +Run: `npx tsc --noEmit -p host/client_app/tsconfig.json` +Expected: PASS + +Run: `npm run build` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add host/client_app framework/cli/simple_module_cli/templates/host/client_app +git commit -m "feat(client): resolve module CSS through per-module Vite aliases" +``` + +--- + +### Task 5: Prove it end-to-end with a real module + +A green unit suite can coexist with Tailwind emitting nothing, so this is the +task that actually proves the feature. + +**Files:** +- Create: `modules/dashboard/dashboard/styles.css` +- Test: `framework/cli/tests/test_module_css_build.py` + +- [ ] **Step 1: Add a real stylesheet to an in-repo module** + +```css +/* Dashboard module styles. Imported into layer(components) by + * modules.generated.css, so these rules always lose to Tailwind utilities. */ +@layer components { + .dashboard-stat-grid { + display: grid; + gap: var(--spacing, 0.25rem); + } +} +``` + +- [ ] **Step 2: Write the failing build test** + +```python +"""Proves module-shipped CSS survives a real Tailwind build.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.mark.slow +class TestModuleCssReachesBundle: + def test_module_class_is_emitted_by_vite_build(self): + """A class defined only in a module's styles.css appears in built CSS.""" + subprocess.run( + ["uv", "run", "smpy", "gen-pages", "--host-dir", "host/client_app"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + ) + subprocess.run( + ["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True + ) + built = list((REPO_ROOT / "host" / "static" / "dist" / "assets").glob("*.css")) + assert built, "no CSS emitted by the build" + combined = "\n".join(p.read_text(encoding="utf-8") for p in built) + assert "dashboard-stat-grid" in combined +``` + +- [ ] **Step 3: Run it and watch it fail, then pass** + +Run: `uv run pytest framework/cli/tests/test_module_css_build.py -v` + +Before Tasks 3-4 land this fails on the missing class. After they land it +passes. If it fails *after*, the alias did not resolve — re-check Task 1's +findings before changing the emitter. + +- [ ] **Step 4: Commit** + +```bash +git add modules/dashboard/dashboard/styles.css framework/cli/tests/test_module_css_build.py +git commit -m "test(client): assert module-shipped CSS reaches the built bundle" +``` + +--- + +### Task 6: SM022 / SM023 diagnostics + +**Files:** +- Create: `framework/core/simple_module_core/diagnostics/_css.py` +- Modify: `framework/core/simple_module_core/diagnostics/_module.py:32-39` +- Test: `framework/core/tests/test_css_diagnostics.py` + +**Interfaces:** +- Consumes: `Diagnostic`, `DiagnosticLevel`, `ModuleBase`, and the `src_dir` + already computed by `ModuleDiagnostics.run`. +- Produces: `check_module_css(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]`, + called exactly like the existing `check_js_workspace_files(mod, src_dir)`. + +- [ ] **Step 1: Write the failing test** + +```python +"""SM022/SM023 — module CSS placed in the wrong file.""" + +from __future__ import annotations + + +class TestModuleCssDiagnostics: + def _mod(self): + from simple_module_core import ModuleBase, ModuleMeta + + class Styled(ModuleBase): + meta = ModuleMeta(name="Styled") + + return Styled() + + def test_sm022_theme_at_rule_in_styles_css(self, tmp_path): + """@theme inside styles.css is inert under layer(components).""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("@theme {\n --color-x: red;\n}\n") + diags = check_module_css(self._mod(), tmp_path) + assert [d.code for d in diags] == ["SM022"] + + def test_sm023_plain_rule_in_theme_css(self, tmp_path): + """An unlayered rule in theme.css outranks every Tailwind utility.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(".card {\n padding: 0;\n}\n") + diags = check_module_css(self._mod(), tmp_path) + assert [d.code for d in diags] == ["SM023"] + + def test_root_block_allowed_in_theme_css(self, tmp_path): + """:root custom-property blocks are legitimate in theme.css.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text( + "@theme {\n --a: 1;\n}\n:root {\n --b: 2;\n}\n" + ) + assert check_module_css(self._mod(), tmp_path) == [] + + def test_nested_at_rule_not_flagged(self, tmp_path): + """Only top-level constructs count — brace depth is tracked.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + "@layer components {\n .x { color: red; }\n}\n" + ) + assert check_module_css(self._mod(), tmp_path) == [] + + def test_comments_stripped_before_scanning(self, tmp_path): + """A commented-out @theme is not a finding.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* @theme { --x: 1; } */\n.y { color: red; }\n") + assert check_module_css(self._mod(), tmp_path) == [] + + def test_clean_module_has_no_findings(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text("@theme {\n --color-x: red;\n}\n") + (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") + assert check_module_css(self._mod(), tmp_path) == [] + + def test_missing_files_are_not_findings(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + assert check_module_css(self._mod(), tmp_path) == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest framework/core/tests/test_css_diagnostics.py -v` +Expected: FAIL — no module `simple_module_core.diagnostics._css` + +- [ ] **Step 3: Implement `_css.py`** + +A line-oriented scan tracking brace depth, comments stripped first. No CSS +parser dependency — this catches the ordinary mistake, not pathological input. + +`THEME_ONLY_AT_RULES = {"@theme", "@custom-variant", "@utility"}` are the +constructs that must live in `theme.css`; `theme.css` may additionally hold +`@font-face`, `@import`, `@charset` and `:root` blocks. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest framework/core/tests/test_css_diagnostics.py -v` +Expected: PASS + +- [ ] **Step 5: Wire into `ModuleDiagnostics.run`** + +Add `diagnostics.extend(check_module_css(mod, src_dir))` to the file-based +check loop, and the import at the top of `_module.py`. + +- [ ] **Step 6: Verify the whole diagnostic suite and `make doctor`** + +Run: `uv run pytest framework/core/tests/ -v` +Run: `make doctor` +Expected: PASS, and `make doctor` reports no new findings for in-repo modules. + +- [ ] **Step 7: Commit** + +```bash +git add framework/core/simple_module_core/diagnostics framework/core/tests/test_css_diagnostics.py +git commit -m "feat(doctor): add SM022/SM023 for misplaced module CSS" +``` + +--- + +### Task 7: Documentation + +**Files:** +- Modify: `docs/module-authoring.md` +- Modify: `CLAUDE.md` +- Modify: `framework/cli/simple_module_cli/templates/module/README.md.tpl` + +- [ ] **Step 1: Add a "Styling" section to `docs/module-authoring.md`** + +Cover: the two-file convention; that `@theme` goes in `theme.css` and component +rules in `styles.css`; why (unlayered CSS beats every layered rule, and a +layered `@theme` is inert); the cascade order DS theme < module theme < app +overrides; and that no packaging change is needed because Hatch already ships +files under the package dir. + +- [ ] **Step 2: Update `CLAUDE.md`** + +Add `theme.css` / `styles.css` to the module-layout tree, and add SM022/SM023 +to the diagnostic-code list. + +- [ ] **Step 3: Mention the convention in the scaffold README template** + +One short paragraph — the scaffold deliberately does *not* create empty CSS +files, so the README is where authors learn the convention exists. + +- [ ] **Step 4: Run the full check** + +Run: `make ci-python-lint` +Run: `make test-py` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add docs CLAUDE.md framework/cli/simple_module_cli/templates/module/README.md.tpl +git commit -m "docs: document the module theme.css/styles.css convention" +``` + +--- + +## Self-Review + +**Spec coverage:** §1 authoring surface → Task 2. §2 emission → Task 3. §3 +cascade → Tasks 3 (layering) + 4 (glob widening). §4 alias/manifest → Tasks 3 +(`modules.assets.json`) + 4 (Vite wiring). §5 diagnostics → Task 6. §6 tests → +Tasks 2, 3, 5, 6. §7 docs → Task 7. "To verify during implementation" → Task 1. +No gaps. + +**Type consistency:** `ModuleAssets` field names (`package_name`, `package_dir`, +`pages_dir`, `theme_css`, `styles_css`) are used identically in Tasks 2 and 3. +The JSON keys (`package_name`, `package`, `pages`, `theme`, `styles`) match +between `render_assets_json` in Task 3 and the `ModuleAsset` TS type in Task 4. +`check_module_css(mod, src_dir)` matches the existing +`check_js_workspace_files(mod, src_dir)` signature it sits beside. + +**Ordering note:** Task 5's build test only passes once Tasks 3 and 4 have both +landed, which is why it is sequenced after them. From 20e9776704cf2c3312ae5dc3e57e813567cf4a4a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:13:26 +0200 Subject: [PATCH 03/14] 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 --- .../plans/2026-08-05-module-css-packaging.md | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-05-module-css-packaging.md b/docs/superpowers/plans/2026-08-05-module-css-packaging.md index d22b3bd5..238ee93d 100644 --- a/docs/superpowers/plans/2026-08-05-module-css-packaging.md +++ b/docs/superpowers/plans/2026-08-05-module-css-packaging.md @@ -47,31 +47,58 @@ they are checked before any code is written. **Files:** - Create (throwaway): `$CLAUDE_JOB_DIR/tmp/css-spike/` -- [ ] **Step 1: Build a minimal Tailwind 4.2.4 spike** +- [x] **Step 1: Build a minimal Tailwind 4.2.4 spike** Create a scratch Vite project that imports a CSS file from outside its root via a `resolve.alias`, and `@source`s a directory by absolute path. Put a class in the aliased CSS and a utility class in a `.tsx` under the absolute `@source` dir. -- [ ] **Step 2: Run the build and inspect output CSS** +- [x] **Step 2: Run the build and inspect output CSS** Confirm two things independently: 1. The aliased `@import` resolved (the module CSS rule appears in output). 2. The absolute `@source` scanned (the utility from that dir appears in output). -- [ ] **Step 3: Record the result** +- [x] **Step 3: Record the result** If the absolute `@source` fails, the `@source` section switches to alias specifiers too, and Task 3's emission changes accordingly. If the alias `@import` fails, stop — the design needs revisiting. -- [ ] **Step 4: Note whether `fs.allow` was needed** +- [x] **Step 4: Note whether `fs.allow` was needed** Run the dev server against a file outside the root without `fs.allow` and see whether the CSS still resolves. `@import` is inlined at transform time, so it may not need whitelisting. +#### Findings (2026-08-06) + +Verified against the versions actually installed in this repo: +`@tailwindcss/vite` **4.2.4**, `tailwindcss` **4.2.4**, `vite` **8.0.10**. + +Both load-bearing claims hold, so **the design proceeds unchanged**: + +| Claim | Result | +|---|---| +| `resolve.alias` governs CSS `@import` | ✅ `@import "#module/spikemod/styles.css" layer(components)` resolved to a directory outside the Vite root and emitted as `@layer components{.spike-module-rule{color:#639}}` | +| Absolute `@source` scans | ✅ `@source "/abs/outside-src"` scanned a `.tsx` outside the root; `.p-7{padding:calc(var(--spacing) * 7)}` was emitted | + +Two further results beyond what the plan asked for: + +- **The unlayered/layered split works as designed.** `theme.css` imported + without a `layer()` clause registered `--color-spikebrand:#123456` as a real + design token, while `styles.css` imported with `layer(components)` landed + inside `@layer components`. The whole chain composes: a token defined in a + module's `theme.css` produced a working `text-spikebrand` utility for a class + used only in an absolute-`@source`d file outside the root. +- **`server.fs.allow` is NOT needed for module CSS.** The dev server, with no + `fs.allow` entry for the external directory, served `/app.css?direct` + (HTTP 200) containing all three signals. `@import` is inlined at transform + time rather than served as a URL, confirming the spec's hypothesis. Task 4 + still pushes each package dir onto `moduleFsAllow` — harmless, and it remains + genuinely required for `.tsx` *pages*, which are served as URLs. + --- ### Task 2: Asset discovery — `compute_module_assets` From 3f1bb8a0afcf0c099e60da9d9f987a84db14c117 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:14:44 +0200 Subject: [PATCH 04/14] feat(hosting): discover per-module theme.css/styles.css assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- framework/cli/tests/test_module_css_assets.py | 203 ++++++++++++++++++ .../hosting/simple_module_hosting/assets.py | 161 ++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 framework/cli/tests/test_module_css_assets.py create mode 100644 framework/hosting/simple_module_hosting/assets.py diff --git a/framework/cli/tests/test_module_css_assets.py b/framework/cli/tests/test_module_css_assets.py new file mode 100644 index 00000000..a8b00cee --- /dev/null +++ b/framework/cli/tests/test_module_css_assets.py @@ -0,0 +1,203 @@ +"""Tests for module CSS asset discovery and emission (`smpy gen-pages`).""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def _make_importable_module(tmp_path: Path, pkg_name: str, klass_name: str): + """Create a real importable package on disk and return a ModuleBase bound to it. + + ``get_module_package_name`` derives the package from the class's + ``__module__``, and ``compute_module_assets`` then resolves that package + via ``importlib.resources.files``. So the package has to genuinely exist + on ``sys.path`` — a mock won't exercise the code path we care about. + """ + from simple_module_core import ModuleBase, ModuleMeta + + pkg = tmp_path / pkg_name + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "__init__.py").write_text("", encoding="utf-8") + if str(tmp_path) not in sys.path: + sys.path.insert(0, str(tmp_path)) + sys.modules.pop(pkg_name, None) + + klass = type( + klass_name, + (ModuleBase,), + {"meta": ModuleMeta(name=klass_name), "__module__": f"{pkg_name}.module"}, + ) + return klass(), pkg + + +class TestComputeModuleAssets: + async def test_detects_theme_and_styles(self, tmp_path): + """A module shipping theme.css/styles.css has both detected.""" + from simple_module_hosting.assets import compute_module_assets + + mod, pkg = _make_importable_module(tmp_path, "styled_mod", "Styled") + (pkg / "pages").mkdir() + (pkg / "theme.css").write_text("@theme { --color-x: red; }\n", encoding="utf-8") + (pkg / "styles.css").write_text(".x { color: red; }\n", encoding="utf-8") + + result = compute_module_assets([mod]) + + assert len(result) == 1 + entry = result[0] + assert entry.package_name == "styled_mod" + assert entry.theme_css is not None and entry.theme_css.name == "theme.css" + assert entry.styles_css is not None and entry.styles_css.name == "styles.css" + assert entry.pages_dir is not None + + async def test_css_only_module_is_included(self, tmp_path): + """A module with CSS but no pages/ still appears — the manifest.json gap.""" + from simple_module_hosting.assets import compute_module_assets + + mod, pkg = _make_importable_module(tmp_path, "cssonly_mod", "CssOnly") + (pkg / "styles.css").write_text(".y { color: blue; }\n", encoding="utf-8") + + result = compute_module_assets([mod]) + + assert [e.name for e in result] == ["CssOnly"] + assert result[0].pages_dir is None + assert result[0].theme_css is None + assert result[0].styles_css is not None + + async def test_module_with_no_assets_is_omitted(self, tmp_path): + """A module contributing nothing frontend-ish is skipped, not an error.""" + from simple_module_hosting.assets import compute_module_assets + + mod, _pkg = _make_importable_module(tmp_path, "headless_mod", "Headless") + + assert compute_module_assets([mod]) == [] + + async def test_preserves_discovery_order(self): + """Order follows discover_modules() (topological), not alphabetical.""" + from simple_module_core import discover_modules + from simple_module_hosting.assets import compute_module_assets + + modules = discover_modules() + result = compute_module_assets(modules) + + names = [e.name for e in result] + discovery_order = [m.meta.name for m in modules] + assert names == [n for n in discovery_order if n in set(names)] + + +def _assets(tmp_path: Path, **overrides): + """Build a single ModuleAssets entry with sensible defaults.""" + from simple_module_hosting.assets import ModuleAssets + + defaults = { + "name": "Gis", + "package_name": "gis", + "package_dir": tmp_path / "gis", + "pages_dir": None, + "theme_css": None, + "styles_css": None, + } + return ModuleAssets(**{**defaults, **overrides}) + + +class TestCssEmission: + async def test_styles_layered_theme_unlayered(self, tmp_path): + """theme.css imports unlayered; styles.css imports into layer(components).""" + from simple_module_hosting.assets import render_modules_css + + entry = _assets( + tmp_path, + theme_css=tmp_path / "gis" / "theme.css", + styles_css=tmp_path / "gis" / "styles.css", + ) + css = render_modules_css([entry], in_repo=lambda _p: False) + + assert '@import "#module/gis/theme.css";' in css + assert '@import "#module/gis/styles.css" layer(components);' in css + # Tokens must be declared before the rules that consume them. + assert css.index("theme.css") < css.index("styles.css") + + async def test_source_skips_in_repo_but_import_does_not(self, tmp_path): + """@source is wheel-only; @import is emitted for every module.""" + from simple_module_hosting.assets import render_modules_css + + entry = _assets( + tmp_path, + name="Local", + package_name="local", + pages_dir=tmp_path / "local" / "pages", + styles_css=tmp_path / "local" / "styles.css", + ) + css = render_modules_css([entry], in_repo=lambda _p: True) + + assert "@source" not in css, "in-repo pages are covered by the static glob" + assert '@import "#module/local/styles.css" layer(components);' in css + + async def test_source_emitted_for_wheel_modules(self, tmp_path): + """A wheel-installed module gets an absolute @source glob.""" + from simple_module_hosting.assets import render_modules_css + + pages = tmp_path / "gis" / "pages" + css = render_modules_css([_assets(tmp_path, pages_dir=pages)], in_repo=lambda _p: False) + + assert f'@source "{pages.as_posix()}/**/*.{{ts,tsx}}";' in css + + async def test_module_without_css_emits_no_import(self, tmp_path): + """Pages-only modules contribute @source but no @import.""" + from simple_module_hosting.assets import render_modules_css + + entry = _assets(tmp_path, pages_dir=tmp_path / "gis" / "pages") + css = render_modules_css([entry], in_repo=lambda _p: False) + + assert "@import" not in css + + async def test_no_relative_paths_anywhere(self, tmp_path): + """The whole point of aliases: generated @import lines never use ../..""" + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + written = write_module_pages_manifest(discover_modules(), tmp_path) + css = written["css"].read_text(encoding="utf-8") + + for line in css.splitlines(): + if line.startswith("@import"): + assert "../" not in line, f"@import must not use a relative path: {line}" + assert '"#module/' in line, f"@import must use the alias prefix: {line}" + + +class TestAssetsManifest: + async def test_writes_assets_json(self, tmp_path): + """modules.assets.json is emitted alongside the existing three files.""" + import json + + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + written = write_module_pages_manifest(discover_modules(), tmp_path) + + assets_path = tmp_path / "modules.assets.json" + assert assets_path.is_file() + assert written["assets"] == assets_path + + data = json.loads(assets_path.read_text(encoding="utf-8")) + entry = data["Dashboard"] + assert entry["package_name"] == "dashboard" + assert entry["package"].endswith("dashboard") + assert set(entry) == {"package_name", "package", "pages", "theme", "styles"} + + async def test_manifest_json_shape_unchanged(self, tmp_path): + """modules.manifest.json stays {name: pages_dir} for downstream vite configs. + + Every downstream app holds its own copy of vite.config.ts and reads + this file; changing its value shape would break them all at once. + """ + import json + + from simple_module_core import discover_modules + from simple_module_hosting.manifest import write_module_pages_manifest + + write_module_pages_manifest(discover_modules(), tmp_path) + + data = json.loads((tmp_path / "modules.manifest.json").read_text(encoding="utf-8")) + assert data, "manifest should not be empty" + assert all(isinstance(v, str) for v in data.values()) diff --git a/framework/hosting/simple_module_hosting/assets.py b/framework/hosting/simple_module_hosting/assets.py new file mode 100644 index 00000000..83727e02 --- /dev/null +++ b/framework/hosting/simple_module_hosting/assets.py @@ -0,0 +1,161 @@ +"""Per-module frontend asset discovery and CSS emission. + +Split out from :mod:`simple_module_hosting.manifest`, which is already at the +repo's 300-line cap. ``manifest.py`` imports from here. + +A module may ship two optional stylesheets beside its ``pages/`` directory: + +* ``theme.css`` — ``@theme`` tokens, ``@custom-variant``, ``@font-face``. + Imported *unlayered*, because a ``@theme`` block inside a cascade layer is + inert and registers no design tokens. +* ``styles.css`` — component rules, keyframes, vendor CSS. Imported into + ``layer(components)``, because unlayered CSS outranks every Tailwind + utility: a module shipping a bare ``.card { padding: 0 }`` would otherwise + silently beat ``p-4``. + +Both are referenced through a per-module Vite alias (``#module/``) rather +than a filesystem path, so the generated CSS never contains something like +``../../../.venv/lib/python3.12/site-packages//styles.css``. +""" + +from __future__ import annotations + +import importlib.resources +import json +import logging +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path + +from simple_module_core import ModuleBase, get_module_package_name + +logger = logging.getLogger(__name__) + +THEME_CSS = "theme.css" +STYLES_CSS = "styles.css" +ALIAS_PREFIX = "#module" + + +@dataclass(frozen=True) +class ModuleAssets: + """Frontend assets a single module contributes.""" + + name: str + package_name: str + package_dir: Path + pages_dir: Path | None + theme_css: Path | None + styles_css: Path | None + + +def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: + """Return per-module frontend assets, preserving discovery order. + + Discovery order matters. ``discover_modules()`` topologically sorts by + ``ModuleMeta.depends_on``, which is the order the host invokes + ``register_*`` hooks in; emitting CSS in that same order means a module + that depends on another can override its dependency's styles. + + A module is included only if it contributes at least one asset. + """ + result: list[ModuleAssets] = [] + for mod in modules: + pkg_name = get_module_package_name(mod) + try: + pkg_root = Path(str(importlib.resources.files(pkg_name))) + except (ModuleNotFoundError, TypeError): + logger.debug( + "Module '%s': package %s not importable — skipping", mod.meta.name, pkg_name + ) + continue + pages_dir = pkg_root / "pages" + theme = pkg_root / THEME_CSS + styles = pkg_root / STYLES_CSS + entry = ModuleAssets( + name=mod.meta.name, + package_name=pkg_name, + package_dir=pkg_root.resolve(), + pages_dir=pages_dir.resolve() if pages_dir.is_dir() else None, + theme_css=theme.resolve() if theme.is_file() else None, + styles_css=styles.resolve() if styles.is_file() else None, + ) + if entry.pages_dir or entry.theme_css or entry.styles_css: + result.append(entry) + return result + + +_CSS_HEADER = """\ +/* AUTO-GENERATED by simple_module_hosting.assets — do not edit by hand. + * Regenerate with: smpy gen-pages + * + * Emission order is module discovery order (topological by depends_on), so a + * dependent module's CSS can override its dependency's. + * + * 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. + */ +""" + + +def render_modules_css( + assets: Sequence[ModuleAssets], + *, + in_repo: Callable[[Path], bool], +) -> str: + """Render the contents of ``modules.generated.css``. + + ``@source`` is emitted only for wheel-installed modules — in-repo module + pages are already covered by the static ``@source`` glob in the host's + ``styles.css``, so emitting an absolute one too would just duplicate it. + ``@import`` is emitted for *every* module, in-repo and wheel alike, because + there is no static-glob equivalent for CSS. + """ + source_lines = [ + f'@source "{e.pages_dir.as_posix()}/**/*.{{ts,tsx}}";' + for e in assets + if e.pages_dir and not in_repo(e.pages_dir) + ] + theme_lines = [ + f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' for e in assets if e.theme_css + ] + style_lines = [ + f'@import "{ALIAS_PREFIX}/{e.package_name}/{STYLES_CSS}" layer(components);' + for e in assets + if e.styles_css + ] + + out = [_CSS_HEADER] + for heading, lines in ( + ("/* ── @source: class scanning (wheel-installed modules) ── */", source_lines), + ("/* ── theme: unlayered, registers @theme tokens ── */", theme_lines), + ("/* ── styles: layer(components), always loses to utilities ── */", style_lines), + ): + if lines: + out.append("") + out.append(heading) + out.extend(lines) + out.append("") + return "\n".join(out) + + +def render_assets_json(assets: Sequence[ModuleAssets]) -> str: + """Render ``modules.assets.json`` — the richer companion to the pages manifest. + + Kept separate from ``modules.manifest.json`` rather than replacing it: + ``vite.config.ts`` is scaffolded *into* each app, so changing the existing + manifest's value shape would break every downstream app at once. This file + is purely additive, and also covers CSS-only modules that never appear in + the pages manifest because they ship no ``pages/``. + """ + payload = { + e.name: { + "package_name": e.package_name, + "package": e.package_dir.as_posix(), + "pages": e.pages_dir.as_posix() if e.pages_dir else None, + "theme": e.theme_css.as_posix() if e.theme_css else None, + "styles": e.styles_css.as_posix() if e.styles_css else None, + } + for e in assets + } + return json.dumps(payload, indent=2, sort_keys=True) + "\n" From e1b84f1526eb102fec02be66b692294c6c8b369c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:16:57 +0200 Subject: [PATCH 05/14] 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//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 --- .../cli/tests/test_module_pages_manifest.py | 11 +++- .../hosting/simple_module_hosting/manifest.py | 61 +++++++++++-------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/framework/cli/tests/test_module_pages_manifest.py b/framework/cli/tests/test_module_pages_manifest.py index 23079665..28833ab4 100644 --- a/framework/cli/tests/test_module_pages_manifest.py +++ b/framework/cli/tests/test_module_pages_manifest.py @@ -33,7 +33,7 @@ class HeadlessMod(ModuleBase): assert "Headless" not in result async def test_write_manifest_emits_json_and_ts(self, tmp_path): - """write_module_pages_manifest emits the JSON manifest, TS glob, and Tailwind CSS files.""" + """write_module_pages_manifest emits the JSON manifest, TS glob, CSS and assets files.""" import json from simple_module_core import discover_modules @@ -45,10 +45,17 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): manifest = tmp_path / "modules.manifest.json" generated = tmp_path / "modules.generated.ts" css = tmp_path / "modules.generated.css" + assets = tmp_path / "modules.assets.json" assert manifest.is_file() assert generated.is_file() assert css.is_file() - assert written == {"manifest": manifest, "generated": generated, "css": css} + assert assets.is_file() + assert written == { + "manifest": manifest, + "generated": generated, + "css": css, + "assets": assets, + } data = json.loads(manifest.read_text(encoding="utf-8")) assert "Dashboard" in data diff --git a/framework/hosting/simple_module_hosting/manifest.py b/framework/hosting/simple_module_hosting/manifest.py index 14a221fb..5fb911a4 100644 --- a/framework/hosting/simple_module_hosting/manifest.py +++ b/framework/hosting/simple_module_hosting/manifest.py @@ -1,10 +1,13 @@ """Frontend pages manifest + per-module JS dependency discovery. -* :func:`write_module_pages_manifest` emits the three generated files Vite +* :func:`write_module_pages_manifest` emits the four generated files Vite and Tailwind read at build time: - ``modules.manifest.json`` — name -> absolute pages dir - ``modules.generated.ts`` — ``import.meta.glob`` per module - - ``modules.generated.css`` — ``@source`` per wheel-installed module + - ``modules.generated.css`` — ``@source`` per wheel-installed module, plus + an ``@import`` per module-shipped stylesheet + - ``modules.assets.json`` — the richer per-module asset record the host's + ``vite.config.ts`` builds its ``#module/`` aliases from * :func:`read_module_package_json` / :func:`collect_module_js_deps` locate the per-module ``package.json`` shipped inside wheels (or alongside the source for editable installs) and aggregate its ``dependencies`` block. @@ -22,6 +25,12 @@ from simple_module_core import ModuleBase, get_module_package_name +from simple_module_hosting.assets import ( + compute_module_assets, + render_assets_json, + render_modules_css, +) + logger = logging.getLogger(__name__) _GENERATED_TS_HEADER = """\ @@ -33,16 +42,6 @@ // pip-installed module wheels are picked up by Vite's import.meta.glob. """ -_GENERATED_CSS_HEADER = """\ -/* AUTO-GENERATED by simple_module_hosting.manifest — do not edit by hand. - * Regenerate with: smpy gen-pages - * - * @source entries for module pages shipped inside pip-installed wheels. - * In-repo modules are covered by the static @source glob in - * host/client_app/styles.css. - */ -""" - def repo_root_from_client_app(client_app_dir: Path) -> Path: """Locate the workspace/repo root that contains ``client_app_dir``. @@ -144,11 +143,16 @@ def write_module_pages_manifest( output_dir: Path, repo_root: Path | None = None, ) -> dict[str, Path]: - """Write the three generated files (manifest JSON, glob TS, Tailwind CSS). + """Write the four generated files (manifest JSON, glob TS, CSS, assets JSON). Returns the paths that were written. Content is written only when different from what's on disk, so booting the host repeatedly in dev does not wake up Vite's file watcher. + + ``modules.manifest.json`` deliberately keeps its ``{name: pages_dir}`` + shape: every downstream app holds its own copy of ``vite.config.ts`` and + reads it, so changing the value shape would break them all at once. The + new per-module asset record goes in ``modules.assets.json`` instead. """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) @@ -180,32 +184,35 @@ def write_module_pages_manifest( lines.append("") generated_text = "\n".join(lines) - # In-repo modules are already covered by the static glob in styles.css; - # only emit @source for wheel-installed ones. pages_map is keyed by - # module name so each pages_dir is unique — no further dedup needed. + # Rendered from the richer asset record rather than pages_map, so that a + # module shipping CSS but no pages/ still contributes its stylesheets. + assets = compute_module_assets(modules) css_path = output_dir / "modules.generated.css" - css_lines: list[str] = [_GENERATED_CSS_HEADER] - for name in sorted(pages_map): - pages_dir = pages_map[name] - if _is_in_repo_module(pages_dir, repo_root): - continue - css_lines.append(f'@source "{pages_dir.as_posix()}";') - css_lines.append("") - css_text = "\n".join(css_lines) + css_text = render_modules_css(assets, in_repo=lambda p: _is_in_repo_module(p, repo_root)) + + assets_path = output_dir / "modules.assets.json" + assets_text = render_assets_json(assets) wrote_manifest = _write_if_changed(manifest_path, manifest_text) wrote_generated = _write_if_changed(generated_path, generated_text) wrote_css = _write_if_changed(css_path, css_text) + wrote_assets = _write_if_changed(assets_path, assets_text) - if wrote_manifest or wrote_generated or wrote_css: + if wrote_manifest or wrote_generated or wrote_css or wrote_assets: logger.info( - "Wrote module pages manifest: %d module(s) -> %s, %s, %s", + "Wrote module pages manifest: %d module(s) -> %s, %s, %s, %s", len(pages_map), manifest_path.name, generated_path.name, css_path.name, + assets_path.name, ) - return {"manifest": manifest_path, "generated": generated_path, "css": css_path} + return { + "manifest": manifest_path, + "generated": generated_path, + "css": css_path, + "assets": assets_path, + } def _glob_pattern_for(pages_dir: Path, output_dir: Path) -> str: From b04a849d288a9c43421507e1ba4eca79d316c67f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:20:31 +0200 Subject: [PATCH 06/14] feat(client): resolve module CSS through per-module Vite aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config.ts reads the new modules.assets.json and registers a "#module/" 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 --- .gitignore | 1 + .../templates/host/client_app/styles.css | 12 +++++-- .../templates/host/client_app/vite.config.ts | 28 +++++++++++++++++ .../hosting/simple_module_hosting/host_cli.py | 4 +-- host/client_app/styles.css | 12 +++++-- host/client_app/vite.config.ts | 31 +++++++++++++++++++ 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 146f98fb..e62553ec 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/framework/cli/simple_module_cli/templates/host/client_app/styles.css b/framework/cli/simple_module_cli/templates/host/client_app/styles.css index e520f7a6..836ac938 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/styles.css +++ b/framework/cli/simple_module_cli/templates/host/client_app/styles.css @@ -1,10 +1,16 @@ @import "@simple-module-py/ui/styles/globals.css"; @import "./modules.generated.css"; -/* App-level source scanning — host code + workspace module pages. - * Wheel-installed modules are covered by modules.generated.css. */ +/* App-level source scanning — host code + workspace modules. + * Wheel-installed modules are covered by modules.generated.css. + * + * The module glob deliberately covers the whole package, not just pages/: + * shared components, hooks and Puck blocks carry Tailwind classes too, and + * scanning pages/ alone meant such a class only reached the bundle when some + * other scanned file happened to use it as well. The {ts,tsx} filter is what + * keeps .py files out of the scan. */ @source "./**/*.{ts,tsx}"; -@source "../../modules/*/*/pages/**/*.{ts,tsx}"; +@source "../../modules/*/*/**/*.{ts,tsx}"; body { margin: 0; diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index d32e2998..265cfe3f 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -68,6 +68,31 @@ if (fs.existsSync(manifestPath)) { } } } + +// Per-module aliases, so generated CSS can say +// @import "#module/gis/styles.css" +// instead of a ../../../.venv/lib/python3.12/site-packages/gis/styles.css +// path that breaks the moment the interpreter version changes. +// +// `@tailwindcss/vite` builds its CSS import resolver with +// `createResolver({ ...config.resolve, ... })`, so `resolve.alias` governs +// CSS `@import` as well as JS — verified against @tailwindcss/vite 4.2.4. +// +// Read from modules.assets.json rather than modules.manifest.json: the +// manifest is keyed off `pages/`, so a module shipping only CSS never +// appears in it. +type ModuleAsset = { package_name: string; package: string }; +const moduleAliases: { find: string; replacement: string }[] = []; +const assetsPath = path.resolve(__dirname, 'modules.assets.json'); +if (fs.existsSync(assetsPath)) { + const assets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8')) as Record; + for (const entry of Object.values(assets)) { + moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package }); + if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package); + } + // Longest `find` first, so `#module/gis` cannot shadow `#module/gis_extra`. + moduleAliases.sort((a, b) => b.find.length - a.find.length); +} const fakeWorkspaceImporter = path.join(fsRoot, 'package.json'); // CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority` @@ -200,6 +225,9 @@ export default defineConfig({ plugins: [moduleBareImportResolver(), react(), tailwindcss()], root: __dirname, resolve: { + // `#module/` -> that module's package directory. Consumed by the + // @import lines in modules.generated.css. + alias: moduleAliases, dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'], }, optimizeDeps: { diff --git a/framework/hosting/simple_module_hosting/host_cli.py b/framework/hosting/simple_module_hosting/host_cli.py index 830c6b79..601ecd58 100644 --- a/framework/hosting/simple_module_hosting/host_cli.py +++ b/framework/hosting/simple_module_hosting/host_cli.py @@ -37,7 +37,7 @@ def gen_pages( ), ] = Path("client_app"), ) -> None: - """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" + """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css,assets.json}.""" logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") if not host_dir.is_dir(): typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True) @@ -47,7 +47,7 @@ def gen_pages( typer.echo( f"Module pages manifest: {len(modules)} module(s) " f"→ {written['manifest'].name}, {written['generated'].name}, " - f"{written['css'].name} in {host_dir}" + f"{written['css'].name}, {written['assets'].name} in {host_dir}" ) diff --git a/host/client_app/styles.css b/host/client_app/styles.css index 02d06d8f..63499c7e 100644 --- a/host/client_app/styles.css +++ b/host/client_app/styles.css @@ -1,7 +1,13 @@ @import "../../packages/ui/src/styles/globals.css"; @import "./modules.generated.css"; -/* App-level source scanning — host code + in-repo module pages. - * Wheel-installed modules are covered by modules.generated.css. */ +/* App-level source scanning — host code + in-repo modules. + * Wheel-installed modules are covered by modules.generated.css. + * + * The module glob deliberately covers the whole package, not just pages/: + * shared components, hooks and Puck blocks carry Tailwind classes too, and + * scanning pages/ alone meant such a class only reached the bundle when some + * other scanned file happened to use it as well. The {ts,tsx} filter is what + * keeps .py files out of the scan. */ @source "./**/*.{ts,tsx}"; -@source "../../modules/*/*/pages/**/*.{ts,tsx}"; +@source "../../modules/*/*/**/*.{ts,tsx}"; diff --git a/host/client_app/vite.config.ts b/host/client_app/vite.config.ts index f26452c1..7ff285b0 100644 --- a/host/client_app/vite.config.ts +++ b/host/client_app/vite.config.ts @@ -53,6 +53,34 @@ for (const pagesDir of Object.values(manifest)) { } } +// Per-module aliases, so generated CSS can say +// @import "#module/gis/styles.css" +// instead of a ../../../.venv/lib/python3.12/site-packages/gis/styles.css +// path that breaks the moment the interpreter version changes. +// +// `@tailwindcss/vite` builds its CSS import resolver with +// `createResolver({ ...config.resolve, ... })`, so `resolve.alias` governs +// CSS `@import` as well as JS — verified against @tailwindcss/vite 4.2.4. +// +// Read from modules.assets.json rather than modules.manifest.json: the +// manifest is keyed off `pages/`, so a module shipping only CSS never +// appears in it. +type ModuleAsset = { package_name: string; package: string }; +const moduleAliases: { find: string; replacement: string }[] = []; +const assetsPath = path.resolve(import.meta.dirname, 'modules.assets.json'); +let moduleAssets: Record = {}; +try { + moduleAssets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8')); +} catch { + // Absent until `smpy gen-pages` runs — proceed with no aliases. +} +for (const entry of Object.values(moduleAssets)) { + moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package }); + if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package); +} +// Longest `find` first, so `#module/gis` cannot shadow `#module/gis_extra`. +moduleAliases.sort((a, b) => b.find.length - a.find.length); + // Gather every bare specifier a module's pages might import. We include // both `dependencies` (deps the module ships its own copy of) and // `peerDependencies` (deps the host is expected to provide, e.g. @@ -161,6 +189,9 @@ export default defineConfig(({ command }) => ({ // feed Vite the per-module tsconfigs. resolve: { tsconfigPaths: true, + // `#module/` -> that module's package directory. Consumed by the + // @import lines in modules.generated.css. + alias: moduleAliases, // Force every importer (host, workspace module, wheel-installed module) // to resolve to one React copy — without it, plugin-react's Fast // Refresh preamble check fires in a realm where its global was never From b4908ed29516d9aa9b85159b9807fa0ebf3f8315 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:23:01 +0200 Subject: [PATCH 07/14] test(client): assert module-shipped CSS reaches the built bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- framework/cli/tests/test_module_css_build.py | 72 ++++++++++++++++++++ modules/dashboard/dashboard/styles.css | 18 +++++ 2 files changed, 90 insertions(+) create mode 100644 framework/cli/tests/test_module_css_build.py create mode 100644 modules/dashboard/dashboard/styles.css diff --git a/framework/cli/tests/test_module_css_build.py b/framework/cli/tests/test_module_css_build.py new file mode 100644 index 00000000..da6c94f8 --- /dev/null +++ b/framework/cli/tests/test_module_css_build.py @@ -0,0 +1,72 @@ +"""Proves module-shipped CSS survives a real Tailwind build. + +Every unit test around `render_modules_css` can stay green while Tailwind +emits nothing at all — the generated `@import` only pays off if Vite resolves +the `#module/` alias and Tailwind keeps the rule. This is the one +assertion that exercises that whole chain, so it drives a genuine build +rather than inspecting the generated text. + +Skipped where a build is impossible: CI's `python-tests` job installs Python +deps only (`make install-py`), so `node_modules` is absent there. The JS jobs +and local runs do have it. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] + +pytestmark = [ + pytest.mark.skipif( + not (REPO_ROOT / "node_modules").is_dir(), + reason="node_modules absent — run `npm install` to exercise the real build", + ), + pytest.mark.skipif(shutil.which("npm") is None, reason="npm not on PATH"), +] + + +class TestModuleCssReachesBundle: + def test_module_class_is_emitted_by_vite_build(self): + """A class defined only in a module's styles.css appears in the built CSS. + + `.dashboard-stat-grid` is declared in modules/dashboard/dashboard/styles.css + and referenced by no TSX anywhere, so it can only reach the bundle via the + generated `@import "#module/dashboard/styles.css"`. + """ + subprocess.run( + [ + "uv", + "run", + "--project", + "host", + "smpy", + "host", + "gen-pages", + "--host-dir=host/client_app", + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + ) + + generated = (REPO_ROOT / "host/client_app/modules.generated.css").read_text( + encoding="utf-8" + ) + assert '@import "#module/dashboard/styles.css" layer(components);' in generated, ( + f"gen-pages did not emit the dashboard stylesheet import:\n{generated}" + ) + + subprocess.run(["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True) + + built = list((REPO_ROOT / "host/static/dist/assets").glob("*.css")) + assert built, "no CSS emitted by the build" + combined = "\n".join(p.read_text(encoding="utf-8") for p in built) + assert "dashboard-stat-grid" in combined, ( + "module-shipped CSS did not reach the bundle — the #module alias " + "most likely failed to resolve" + ) diff --git a/modules/dashboard/dashboard/styles.css b/modules/dashboard/dashboard/styles.css new file mode 100644 index 00000000..bbfc973a --- /dev/null +++ b/modules/dashboard/dashboard/styles.css @@ -0,0 +1,18 @@ +/* Dashboard module component styles. + * + * Picked up automatically by `smpy host gen-pages`, which emits + * @import "#module/dashboard/styles.css" layer(components); + * into host/client_app/modules.generated.css. Nothing needs to be added to + * the host's styles.css by hand. + * + * Rules here live in layer(components) and therefore always lose to a + * Tailwind utility, so a consuming app can still override any of this with + * a class on the element. Design tokens belong in theme.css instead — a + * @theme block is inert inside a cascade layer. + */ +@layer components { + .dashboard-stat-grid { + display: grid; + gap: var(--spacing, 0.25rem); + } +} From c6ff9e562d18db4a3b94c94b489f670c22217933 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:26:16 +0200 Subject: [PATCH 08/14] feat(doctor): add SM022/SM023 for misplaced module CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../simple_module_core/diagnostics/_css.py | 150 +++++++++++++++++ .../simple_module_core/diagnostics/_module.py | 2 + framework/core/tests/test_css_diagnostics.py | 155 ++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 framework/core/simple_module_core/diagnostics/_css.py create mode 100644 framework/core/tests/test_css_diagnostics.py diff --git a/framework/core/simple_module_core/diagnostics/_css.py b/framework/core/simple_module_core/diagnostics/_css.py new file mode 100644 index 00000000..e3418826 --- /dev/null +++ b/framework/core/simple_module_core/diagnostics/_css.py @@ -0,0 +1,150 @@ +"""SM022/SM023: module CSS placed in the file with the wrong cascade position. + +A module ships its styles in two files, and which one a construct lands in +decides how it cascades: + +* ``theme.css`` is imported **unlayered**, because a ``@theme`` block inside a + cascade layer registers no design tokens at all. Unlayered CSS also outranks + every layered rule, so a plain rule here would beat any Tailwind utility. +* ``styles.css`` is imported into ``layer(components)``, so its rules always + lose to a utility — which is what you want for component CSS, and useless + for ``@theme``. + +Both codes are warnings, not errors: the misplaced CSS is legal and builds +fine, it just cascades in a way the author almost certainly did not intend. + +Detection is a line-oriented scan that tracks brace depth, not a CSS parse — +adding a CSS parser as a runtime dependency to power a lint is not worth it. +This catches the ordinary mistake of putting a construct in the wrong file; it +is not a conformance checker, and pathological input (an at-rule assembled by +preprocessing, say) is out of scope. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel + +if TYPE_CHECKING: + from simple_module_core.module import ModuleBase + +THEME_CSS = "theme.css" +STYLES_CSS = "styles.css" + +# Constructs that only do anything unlayered, so they belong in theme.css. +THEME_ONLY_AT_RULES = ("@theme", "@custom-variant", "@utility") + +_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) + + +def _strip_comments(text: str) -> str: + """Blank out comments, preserving newlines so line numbers stay accurate.""" + return _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), text) + + +def _top_level_preludes(text: str) -> list[tuple[str, int]]: + """Return ``(prelude, line_number)`` for every top-level construct. + + A "prelude" is the selector or at-rule text preceding a top-level ``{``, + or a whole statement at-rule terminated by ``;`` at depth 0. Anything + nested inside braces is skipped, which is what keeps a rule inside + ``@layer components { ... }`` from being mistaken for a top-level one. + """ + out: list[tuple[str, int]] = [] + depth = 0 + buf: list[str] = [] + line = 1 + buf_line = 1 + for ch in text: + if ch == "{": + if depth == 0: + prelude = "".join(buf).strip() + if prelude: + out.append((prelude, buf_line)) + buf = [] + depth += 1 + elif ch == "}": + depth = max(0, depth - 1) + if depth == 0: + buf = [] + elif depth == 0: + if ch == ";": + prelude = "".join(buf).strip() + if prelude: + out.append((prelude, buf_line)) + buf = [] + elif buf: + buf.append(ch) + elif not ch.isspace(): + # Start the buffer at the first non-space character, so + # buf_line points at the construct rather than at whatever + # blank lines preceded it. + buf_line = line + buf.append(ch) + if ch == "\n": + line += 1 + return out + + +def _is_root_selector(prelude: str) -> bool: + """True if every comma-separated part of the selector is :root-based. + + ``:root``, ``:root[data-theme="dark"]`` and a comma-separated list of both + are the normal way to declare design tokens, so they are legitimate in + theme.css even though they are plain rules. + """ + parts = [p.strip() for p in prelude.split(",") if p.strip()] + return bool(parts) and all(p.startswith(":root") for p in parts) + + +def check_module_css(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]: + """Warn when module CSS sits in the file with the wrong cascade position.""" + diagnostics: list[Diagnostic] = [] + + styles = src_dir / STYLES_CSS + if styles.is_file(): + for prelude, line in _top_level_preludes(_strip_comments(styles.read_text("utf-8"))): + at_rule = prelude.split()[0].lower() if prelude.split() else "" + if at_rule in THEME_ONLY_AT_RULES: + diagnostics.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM022", + message=( + f"{at_rule} in {STYLES_CSS} is inert — that file is " + "imported into layer(components)" + ), + module_name=mod.meta.name, + file=f"{styles}:{line}", + suggestion=( + f"Move the {at_rule} block to {src_dir / THEME_CSS}, which is " + "imported unlayered so its tokens actually register" + ), + ) + ) + + theme = src_dir / THEME_CSS + if theme.is_file(): + for prelude, line in _top_level_preludes(_strip_comments(theme.read_text("utf-8"))): + if prelude.startswith("@") or _is_root_selector(prelude): + continue + diagnostics.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM023", + message=( + f"Unlayered rule '{prelude}' in {THEME_CSS} outranks every Tailwind utility" + ), + module_name=mod.meta.name, + file=f"{theme}:{line}", + suggestion=( + f"Move it to {src_dir / STYLES_CSS}, which is imported into " + "layer(components) so utilities still win" + ), + ) + ) + + return diagnostics diff --git a/framework/core/simple_module_core/diagnostics/_module.py b/framework/core/simple_module_core/diagnostics/_module.py index 64a2e836..e5fe1323 100644 --- a/framework/core/simple_module_core/diagnostics/_module.py +++ b/framework/core/simple_module_core/diagnostics/_module.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from simple_module_core.diagnostics._coupling import check_framework_module_coupling +from simple_module_core.diagnostics._css import check_module_css from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls from simple_module_core.diagnostics._js_workspace import check_js_workspace_files from simple_module_core.diagnostics._pages import check_pages, find_render_calls @@ -37,6 +38,7 @@ def run(self, modules: list[ModuleBase]) -> list[Diagnostic]: diagnostics.extend(check_pages(mod, src_dir, rendered_pages)) diagnostics.extend(check_js_workspace_files(mod, src_dir)) diagnostics.extend(check_inertia_api_calls(mod, src_dir)) + diagnostics.extend(check_module_css(mod, src_dir)) return diagnostics diff --git a/framework/core/tests/test_css_diagnostics.py b/framework/core/tests/test_css_diagnostics.py new file mode 100644 index 00000000..c812a4c4 --- /dev/null +++ b/framework/core/tests/test_css_diagnostics.py @@ -0,0 +1,155 @@ +"""SM022/SM023 — module CSS placed in the wrong file. + +The theme.css / styles.css split is what makes the cascade rules structural +rather than merely documented, so putting a construct in the wrong file +produces surprising-but-legal CSS. Both codes are warnings for that reason: +the build succeeds either way. +""" + +from __future__ import annotations + + +def _mod(): + from simple_module_core import ModuleBase, ModuleMeta + + class Styled(ModuleBase): + meta = ModuleMeta(name="Styled") + + return Styled() + + +class TestSm022ThemeConstructsInStylesCss: + def test_theme_at_rule_in_styles_css(self, tmp_path): + """@theme inside styles.css is inert — styles.css is imported layered.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("@theme {\n --color-x: red;\n}\n") + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].level.value == "warning" + + def test_custom_variant_in_styles_css(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("@custom-variant dark (&:where(.dark, .dark *));\n") + + assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + + def test_utility_at_rule_in_styles_css(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("@utility tab-4 {\n tab-size: 4;\n}\n") + + assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + + def test_reports_the_offending_line(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + "@layer components {\n .x { color: red; }\n}\n\n@theme {\n --a: 1;\n}\n" + ) + + diags = check_module_css(_mod(), tmp_path) + + assert len(diags) == 1 + # Line number rides in `file` as path:line, matching _inertia_api.py. + assert diags[0].file.endswith("styles.css:5") + + +class TestSm023UnlayeredRulesInThemeCss: + def test_plain_rule_in_theme_css(self, tmp_path): + """An unlayered rule in theme.css outranks every Tailwind utility.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(".card {\n padding: 0;\n}\n") + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM023"] + assert diags[0].level.value == "warning" + + def test_root_block_allowed(self, tmp_path): + """:root custom-property blocks are legitimate in theme.css.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n:root {\n --b: 2;\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_root_variants_allowed(self, tmp_path): + """Dark-mode token blocks keyed off :root are the normal pattern.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root,\n:root[data-theme="dark"] {\n --b: 2;\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + + def test_font_face_allowed(self, tmp_path): + """@font-face is explicitly part of what theme.css is for.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text( + "@font-face {\n font-family: X;\n src: url(x.woff2);\n}\n" + ) + + assert check_module_css(_mod(), tmp_path) == [] + + +class TestScannerRobustness: + def test_nested_at_rule_not_flagged(self, tmp_path): + """Only top-level constructs count — brace depth is tracked.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_nested_rule_in_theme_css_not_flagged(self, tmp_path): + """A rule nested inside @theme is not a top-level rule.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_comments_stripped_before_scanning(self, tmp_path): + """A commented-out @theme is not a finding.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* @theme { --x: 1; } */\n.y { color: red; }\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_comment_does_not_shift_line_numbers(self, tmp_path): + """Stripping comments must preserve newlines, or line numbers drift.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* a\n multi-line\n comment */\n@theme {\n}\n") + + diags = check_module_css(_mod(), tmp_path) + + assert [d.file.rsplit(":", 1)[1] for d in diags] == ["4"] + + def test_clean_module_has_no_findings(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text("@theme {\n --color-x: red;\n}\n") + (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_missing_files_are_not_findings(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + assert check_module_css(_mod(), tmp_path) == [] + + def test_import_statement_allowed_in_either_file(self, tmp_path): + """A statement at-rule ending in ; is not a rule block.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text('@import "other.css";\n') + (tmp_path / "styles.css").write_text('@charset "utf-8";\n') + + assert check_module_css(_mod(), tmp_path) == [] From de45e030cb1469676b2046b2540e95f540a9e8ef Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 06:30:28 +0200 Subject: [PATCH 09/14] docs: document the module theme.css/styles.css convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 = [""] 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 --- CLAUDE.md | 9 +- docs/module-authoring.md | 80 ++++++++++++++++ .../plans/2026-08-05-module-css-packaging.md | 94 +++++++++---------- .../templates/module/README.md.tpl | 18 ++++ 4 files changed, 148 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c10af6fb..54db095f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,8 +63,15 @@ modules/// ├── 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/.json ``` +Both CSS files are optional and auto-detected; `gen-pages` emits an +`@import "#module//..."` 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). @@ -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.` (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.` (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 diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 8b57cb48..d856d33c 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -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/` 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 @@ -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/` 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()` diff --git a/docs/superpowers/plans/2026-08-05-module-css-packaging.md b/docs/superpowers/plans/2026-08-05-module-css-packaging.md index 238ee93d..9b759ca6 100644 --- a/docs/superpowers/plans/2026-08-05-module-css-packaging.md +++ b/docs/superpowers/plans/2026-08-05-module-css-packaging.md @@ -117,7 +117,7 @@ Two further results beyond what the plan asked for: — preserves input (discovery) order; includes a module if it has **any** of pages/theme/styles. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```python """Tests for module CSS asset discovery and emission.""" @@ -195,12 +195,12 @@ class TestComputeModuleAssets: assert names == [n for n in discovery_order if n in set(names)] ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `uv run pytest framework/cli/tests/test_module_css_assets.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'simple_module_hosting.assets'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python """Per-module frontend asset discovery (pages + CSS). @@ -271,12 +271,12 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: return result ``` -- [ ] **Step 4: Run test to verify it passes** +- [x] **Step 4: Run test to verify it passes** Run: `uv run pytest framework/cli/tests/test_module_css_assets.py -v` Expected: PASS -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add framework/hosting/simple_module_hosting/assets.py framework/cli/tests/test_module_css_assets.py @@ -300,7 +300,7 @@ git commit -m "feat(hosting): discover per-module theme.css/styles.css assets" - `render_assets_json(assets) -> str` - `write_module_pages_manifest` returns an extra `"assets"` key. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```python class TestCssEmission: @@ -388,13 +388,13 @@ class TestCssEmission: assert all(isinstance(v, str) for v in data.values()) ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `uv run pytest framework/cli/tests/test_module_css_assets.py -v` Expected: FAIL — `render_modules_css` / `render_assets_json` undefined, and `written` has no `"assets"` key. -- [ ] **Step 3: Implement the emitters in `assets.py`** +- [x] **Step 3: Implement the emitters in `assets.py`** ```python ALIAS_PREFIX = "#module" @@ -431,9 +431,7 @@ def render_modules_css( if e.pages_dir and not in_repo(e.pages_dir) ] theme_lines = [ - f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' - for e in assets - if e.theme_css + f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' for e in assets if e.theme_css ] style_lines = [ f'@import "{ALIAS_PREFIX}/{e.package_name}/{STYLES_CSS}" layer(components);' @@ -473,7 +471,7 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str: Add `import json` and `from collections.abc import Callable, Sequence` at the top of `assets.py`. -- [ ] **Step 4: Rewire `manifest.py`** +- [x] **Step 4: Rewire `manifest.py`** Replace the CSS-emission block in `write_module_pages_manifest` with calls to the new renderers, add the `modules.assets.json` write, and include `"assets"` @@ -481,25 +479,23 @@ in the returned dict. Delete the now-unused `_GENERATED_CSS_HEADER`. Keep `_is_in_repo_module` and pass it as the `in_repo` callable: ```python - assets = compute_module_assets(modules) - css_text = render_modules_css( - assets, in_repo=lambda p: _is_in_repo_module(p, repo_root) - ) - assets_path = output_dir / "modules.assets.json" - wrote_assets = _write_if_changed(assets_path, render_assets_json(assets)) +assets = compute_module_assets(modules) +css_text = render_modules_css(assets, in_repo=lambda p: _is_in_repo_module(p, repo_root)) +assets_path = output_dir / "modules.assets.json" +wrote_assets = _write_if_changed(assets_path, render_assets_json(assets)) ``` -- [ ] **Step 5: Run tests to verify they pass** +- [x] **Step 5: Run tests to verify they pass** Run: `uv run pytest framework/cli/tests/test_module_css_assets.py framework/cli/tests/test_module_pages_manifest.py -v` Expected: PASS (both files — the existing manifest tests must not regress) -- [ ] **Step 6: Check the file-size cap** +- [x] **Step 6: Check the file-size cap** Run: `uv run python scripts/check_file_size.py` Expected: PASS — `manifest.py` must still be under 300 lines. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add framework/hosting/simple_module_hosting/ framework/cli/tests/test_module_css_assets.py @@ -521,7 +517,7 @@ git commit -m "feat(hosting): emit aliased module CSS imports and modules.assets - Produces: a `#module/` alias per module, resolvable from CSS and TSX alike. -- [ ] **Step 1: Read `modules.assets.json` in `vite.config.ts`** +- [x] **Step 1: Read `modules.assets.json` in `vite.config.ts`** Add alongside the existing manifest read, leaving that read intact: @@ -548,25 +544,25 @@ if (fs.existsSync(assetsPath)) { } ``` -- [ ] **Step 2: Register the aliases** +- [x] **Step 2: Register the aliases** In the existing `resolve:` block, add `alias: moduleAliases,` beside `tsconfigPaths` and `dedupe`. -- [ ] **Step 3: Widen the in-repo `@source` glob** +- [x] **Step 3: Widen the in-repo `@source` glob** In both `styles.css` files, change `@source "../../modules/*/*/pages/**/*.{ts,tsx}";` to `@source "../../modules/*/*/**/*.{ts,tsx}";` so `.ts`/`.tsx` outside `pages/` is scanned. The `{ts,tsx}` filter keeps `.py` out. -- [ ] **Step 4: Mirror steps 1-2 into the scaffold template** +- [x] **Step 4: Mirror steps 1-2 into the scaffold template** Apply the same edits to `framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts` so new `smpy new` apps get aliases from the start. -- [ ] **Step 5: Verify the templates still typecheck and the app builds** +- [x] **Step 5: Verify the templates still typecheck and the app builds** Run: `npx tsc --noEmit -p host/client_app/tsconfig.json` Expected: PASS @@ -574,7 +570,7 @@ Expected: PASS Run: `npm run build` Expected: PASS -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add host/client_app framework/cli/simple_module_cli/templates/host/client_app @@ -592,7 +588,7 @@ task that actually proves the feature. - Create: `modules/dashboard/dashboard/styles.css` - Test: `framework/cli/tests/test_module_css_build.py` -- [ ] **Step 1: Add a real stylesheet to an in-repo module** +- [x] **Step 1: Add a real stylesheet to an in-repo module** ```css /* Dashboard module styles. Imported into layer(components) by @@ -605,7 +601,7 @@ task that actually proves the feature. } ``` -- [ ] **Step 2: Write the failing build test** +- [x] **Step 2: Write the failing build test** ```python """Proves module-shipped CSS survives a real Tailwind build.""" @@ -630,16 +626,14 @@ class TestModuleCssReachesBundle: check=True, capture_output=True, ) - subprocess.run( - ["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True - ) + subprocess.run(["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True) built = list((REPO_ROOT / "host" / "static" / "dist" / "assets").glob("*.css")) assert built, "no CSS emitted by the build" combined = "\n".join(p.read_text(encoding="utf-8") for p in built) assert "dashboard-stat-grid" in combined ``` -- [ ] **Step 3: Run it and watch it fail, then pass** +- [x] **Step 3: Run it and watch it fail, then pass** Run: `uv run pytest framework/cli/tests/test_module_css_build.py -v` @@ -647,7 +641,7 @@ Before Tasks 3-4 land this fails on the missing class. After they land it passes. If it fails *after*, the alias did not resolve — re-check Task 1's findings before changing the emitter. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add modules/dashboard/dashboard/styles.css framework/cli/tests/test_module_css_build.py @@ -669,7 +663,7 @@ git commit -m "test(client): assert module-shipped CSS reaches the built bundle" - Produces: `check_module_css(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]`, called exactly like the existing `check_js_workspace_files(mod, src_dir)`. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```python """SM022/SM023 — module CSS placed in the wrong file.""" @@ -706,18 +700,14 @@ class TestModuleCssDiagnostics: """:root custom-property blocks are legitimate in theme.css.""" from simple_module_core.diagnostics._css import check_module_css - (tmp_path / "theme.css").write_text( - "@theme {\n --a: 1;\n}\n:root {\n --b: 2;\n}\n" - ) + (tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n:root {\n --b: 2;\n}\n") assert check_module_css(self._mod(), tmp_path) == [] def test_nested_at_rule_not_flagged(self, tmp_path): """Only top-level constructs count — brace depth is tracked.""" from simple_module_core.diagnostics._css import check_module_css - (tmp_path / "styles.css").write_text( - "@layer components {\n .x { color: red; }\n}\n" - ) + (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") assert check_module_css(self._mod(), tmp_path) == [] def test_comments_stripped_before_scanning(self, tmp_path): @@ -740,12 +730,12 @@ class TestModuleCssDiagnostics: assert check_module_css(self._mod(), tmp_path) == [] ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `uv run pytest framework/core/tests/test_css_diagnostics.py -v` Expected: FAIL — no module `simple_module_core.diagnostics._css` -- [ ] **Step 3: Implement `_css.py`** +- [x] **Step 3: Implement `_css.py`** A line-oriented scan tracking brace depth, comments stripped first. No CSS parser dependency — this catches the ordinary mistake, not pathological input. @@ -754,23 +744,23 @@ parser dependency — this catches the ordinary mistake, not pathological input. constructs that must live in `theme.css`; `theme.css` may additionally hold `@font-face`, `@import`, `@charset` and `:root` blocks. -- [ ] **Step 4: Run test to verify it passes** +- [x] **Step 4: Run test to verify it passes** Run: `uv run pytest framework/core/tests/test_css_diagnostics.py -v` Expected: PASS -- [ ] **Step 5: Wire into `ModuleDiagnostics.run`** +- [x] **Step 5: Wire into `ModuleDiagnostics.run`** Add `diagnostics.extend(check_module_css(mod, src_dir))` to the file-based check loop, and the import at the top of `_module.py`. -- [ ] **Step 6: Verify the whole diagnostic suite and `make doctor`** +- [x] **Step 6: Verify the whole diagnostic suite and `make doctor`** Run: `uv run pytest framework/core/tests/ -v` Run: `make doctor` Expected: PASS, and `make doctor` reports no new findings for in-repo modules. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add framework/core/simple_module_core/diagnostics framework/core/tests/test_css_diagnostics.py @@ -786,7 +776,7 @@ git commit -m "feat(doctor): add SM022/SM023 for misplaced module CSS" - Modify: `CLAUDE.md` - Modify: `framework/cli/simple_module_cli/templates/module/README.md.tpl` -- [ ] **Step 1: Add a "Styling" section to `docs/module-authoring.md`** +- [x] **Step 1: Add a "Styling" section to `docs/module-authoring.md`** Cover: the two-file convention; that `@theme` goes in `theme.css` and component rules in `styles.css`; why (unlayered CSS beats every layered rule, and a @@ -794,23 +784,23 @@ layered `@theme` is inert); the cascade order DS theme < module theme < app overrides; and that no packaging change is needed because Hatch already ships files under the package dir. -- [ ] **Step 2: Update `CLAUDE.md`** +- [x] **Step 2: Update `CLAUDE.md`** Add `theme.css` / `styles.css` to the module-layout tree, and add SM022/SM023 to the diagnostic-code list. -- [ ] **Step 3: Mention the convention in the scaffold README template** +- [x] **Step 3: Mention the convention in the scaffold README template** One short paragraph — the scaffold deliberately does *not* create empty CSS files, so the README is where authors learn the convention exists. -- [ ] **Step 4: Run the full check** +- [x] **Step 4: Run the full check** Run: `make ci-python-lint` Run: `make test-py` Expected: PASS -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add docs CLAUDE.md framework/cli/simple_module_cli/templates/module/README.md.tpl diff --git a/framework/cli/simple_module_cli/templates/module/README.md.tpl b/framework/cli/simple_module_cli/templates/module/README.md.tpl index f7ad5920..df8a4d47 100644 --- a/framework/cli/simple_module_cli/templates/module/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/module/README.md.tpl @@ -39,6 +39,24 @@ Hosts mount the bundle automatically at when it exists, an empty dict otherwise (so dev without a build step doesn't fail). +## Styling (optional) + +This module can ship CSS that lands in the host's Tailwind build with no +wiring on the host side. Create either file beside `pages/` and it is +picked up automatically — the scaffold deliberately does not create them +empty, since a file that is always imported but usually empty is noise: + +- `{{PACKAGE_NAME}}/theme.css` — `@theme` tokens, `@custom-variant`, + `@font-face`. Imported **unlayered**, because a `@theme` block inside a + cascade layer registers nothing. +- `{{PACKAGE_NAME}}/styles.css` — component rules, keyframes, vendor CSS. + Imported into **`layer(components)`**, so these rules always lose to a + Tailwind utility and a consuming app can still override them. + +Putting a construct in the wrong file builds fine but cascades in a way you +probably did not intend, so `make doctor` warns: `SM022` for `@theme` in +`styles.css`, `SM023` for an unlayered rule in `theme.css`. + ## Continuous integration Two workflows live under `.github/workflows/`: From 4cd528d5fe6180d70f5c1c3b0a89557215035115 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 07:32:55 +0200 Subject: [PATCH 10/14] fix(doctor): keep braces inside strings out of the CSS depth counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../templates/host/client_app/vite.config.ts | 22 +++-- .../simple_module_core/diagnostics/_css.py | 82 ++++++++++++++----- framework/core/tests/test_css_diagnostics.py | 56 ++++++++++++- host/client_app/vite.config.ts | 5 +- 4 files changed, 134 insertions(+), 31 deletions(-) diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index 265cfe3f..b1244148 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -84,15 +84,21 @@ if (fs.existsSync(manifestPath)) { type ModuleAsset = { package_name: string; package: string }; const moduleAliases: { find: string; replacement: string }[] = []; const assetsPath = path.resolve(__dirname, 'modules.assets.json'); -if (fs.existsSync(assetsPath)) { - const assets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8')) as Record; - for (const entry of Object.values(assets)) { - moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package }); - if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package); - } - // Longest `find` first, so `#module/gis` cannot shadow `#module/gis_extra`. - moduleAliases.sort((a, b) => b.find.length - a.find.length); +let moduleAssets: Record = {}; +try { + moduleAssets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8')); +} catch { + // Absent until `smpy gen-pages` runs — proceed with no aliases. +} +for (const entry of Object.values(moduleAssets)) { + moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package }); + if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package); } +// Keep the alias list in a stable, longest-first order. Vite matches a string +// `find` on exact equality or a `/`-bounded prefix, so `#module/gis` could not +// swallow `#module/gis_extra` in any order — this is just determinism, not a +// correctness fix. +moduleAliases.sort((a, b) => b.find.length - a.find.length); const fakeWorkspaceImporter = path.join(fsRoot, 'package.json'); // CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority` diff --git a/framework/core/simple_module_core/diagnostics/_css.py b/framework/core/simple_module_core/diagnostics/_css.py index e3418826..04414c7c 100644 --- a/framework/core/simple_module_core/diagnostics/_css.py +++ b/framework/core/simple_module_core/diagnostics/_css.py @@ -22,7 +22,6 @@ from __future__ import annotations -import re from pathlib import Path from typing import TYPE_CHECKING @@ -37,13 +36,6 @@ # Constructs that only do anything unlayered, so they belong in theme.css. THEME_ONLY_AT_RULES = ("@theme", "@custom-variant", "@utility") -_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) - - -def _strip_comments(text: str) -> str: - """Blank out comments, preserving newlines so line numbers stay accurate.""" - return _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), text) - def _top_level_preludes(text: str) -> list[tuple[str, int]]: """Return ``(prelude, line_number)`` for every top-level construct. @@ -52,30 +44,78 @@ def _top_level_preludes(text: str) -> list[tuple[str, int]]: or a whole statement at-rule terminated by ``;`` at depth 0. Anything nested inside braces is skipped, which is what keeps a rule inside ``@layer components { ... }`` from being mistaken for a top-level one. + + Comments and quoted strings are consumed inline rather than pre-stripped, + because a brace inside either is not structural. ``content: "{"`` in an + icon-font rule would otherwise desynchronise the depth counter for the + whole rest of the file — every later top-level construct would be read as + nested and silently skipped. """ out: list[tuple[str, int]] = [] depth = 0 buf: list[str] = [] line = 1 buf_line = 1 - for ch in text: + quote: str | None = None + i = 0 + n = len(text) + + def flush() -> None: + prelude = "".join(buf).strip() + if prelude: + out.append((prelude, buf_line)) + + while i < n: + ch = text[i] + + if quote is not None: + # Inside a string nothing is structural. Escapes are consumed + # whole so a trailing \" doesn't look like the closing quote. + if ch == "\\" and i + 1 < n: + if depth == 0 and buf: + buf.append(text[i : i + 2]) + if text[i + 1] == "\n": + line += 1 + i += 2 + continue + if ch == quote: + quote = None + if depth == 0 and buf: + buf.append(ch) + if ch == "\n": + line += 1 + i += 1 + continue + + if ch == "/" and i + 1 < n and text[i + 1] == "*": + end = text.find("*/", i + 2) + end = n if end == -1 else end + 2 + line += text.count("\n", i, end) + i = end + continue + + if ch in "\"'": + quote = ch + if depth == 0: + if not buf: + buf_line = line + buf.append(ch) + i += 1 + continue + if ch == "{": if depth == 0: - prelude = "".join(buf).strip() - if prelude: - out.append((prelude, buf_line)) - buf = [] + flush() + buf.clear() depth += 1 elif ch == "}": depth = max(0, depth - 1) if depth == 0: - buf = [] + buf.clear() elif depth == 0: if ch == ";": - prelude = "".join(buf).strip() - if prelude: - out.append((prelude, buf_line)) - buf = [] + flush() + buf.clear() elif buf: buf.append(ch) elif not ch.isspace(): @@ -84,8 +124,10 @@ def _top_level_preludes(text: str) -> list[tuple[str, int]]: # blank lines preceded it. buf_line = line buf.append(ch) + if ch == "\n": line += 1 + i += 1 return out @@ -106,7 +148,7 @@ def check_module_css(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]: styles = src_dir / STYLES_CSS if styles.is_file(): - for prelude, line in _top_level_preludes(_strip_comments(styles.read_text("utf-8"))): + for prelude, line in _top_level_preludes(styles.read_text("utf-8")): at_rule = prelude.split()[0].lower() if prelude.split() else "" if at_rule in THEME_ONLY_AT_RULES: diagnostics.append( @@ -128,7 +170,7 @@ def check_module_css(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]: theme = src_dir / THEME_CSS if theme.is_file(): - for prelude, line in _top_level_preludes(_strip_comments(theme.read_text("utf-8"))): + for prelude, line in _top_level_preludes(theme.read_text("utf-8")): if prelude.startswith("@") or _is_root_selector(prelude): continue diagnostics.append( diff --git a/framework/core/tests/test_css_diagnostics.py b/framework/core/tests/test_css_diagnostics.py index c812a4c4..152a0e98 100644 --- a/framework/core/tests/test_css_diagnostics.py +++ b/framework/core/tests/test_css_diagnostics.py @@ -114,7 +114,7 @@ def test_nested_rule_in_theme_css_not_flagged(self, tmp_path): assert check_module_css(_mod(), tmp_path) == [] - def test_comments_stripped_before_scanning(self, tmp_path): + def test_commented_out_construct_is_not_a_finding(self, tmp_path): """A commented-out @theme is not a finding.""" from simple_module_core.diagnostics._css import check_module_css @@ -123,7 +123,7 @@ def test_comments_stripped_before_scanning(self, tmp_path): assert check_module_css(_mod(), tmp_path) == [] def test_comment_does_not_shift_line_numbers(self, tmp_path): - """Stripping comments must preserve newlines, or line numbers drift.""" + """Newlines inside a skipped comment must still be counted.""" from simple_module_core.diagnostics._css import check_module_css (tmp_path / "styles.css").write_text("/* a\n multi-line\n comment */\n@theme {\n}\n") @@ -145,6 +145,58 @@ def test_missing_files_are_not_findings(self, tmp_path): assert check_module_css(_mod(), tmp_path) == [] + def test_open_brace_in_a_string_does_not_hide_later_findings(self, tmp_path): + """A brace inside a string must not desync the depth counter. + + An icon-font rule like `content: "{"` used to leave the scanner + permanently one level deep, so every later top-level construct read as + nested — silently swallowing the very @theme SM022 exists to catch. + """ + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text('.icon { content: "{"; }\n@theme {\n --a: 1;\n}\n') + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + + def test_close_brace_in_a_string_is_not_a_finding(self, tmp_path): + """A closing brace inside a string must not end the block early.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root {\n --icon-close: "}";\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + + def test_escaped_quote_in_a_string(self, tmp_path): + """A backslash-escaped quote does not terminate the string.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root {\n --q: "a\\"{b";\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + + def test_single_quoted_strings_handled(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".icon { content: '{'; }\n@utility tab-4 {\n tab-size: 4;\n}\n" + ) + + assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + + def test_brace_inside_a_comment_is_not_structural(self, tmp_path): + """An unbalanced brace in a comment must not shift depth either.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* } { */\n@theme {\n --a: 1;\n}\n") + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + def test_import_statement_allowed_in_either_file(self, tmp_path): """A statement at-rule ending in ; is not a rule block.""" from simple_module_core.diagnostics._css import check_module_css diff --git a/host/client_app/vite.config.ts b/host/client_app/vite.config.ts index 7ff285b0..fff8658b 100644 --- a/host/client_app/vite.config.ts +++ b/host/client_app/vite.config.ts @@ -78,7 +78,10 @@ for (const entry of Object.values(moduleAssets)) { moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package }); if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package); } -// Longest `find` first, so `#module/gis` cannot shadow `#module/gis_extra`. +// Keep the alias list in a stable, longest-first order. Vite matches a string +// `find` on exact equality or a `/`-bounded prefix, so `#module/gis` could not +// swallow `#module/gis_extra` in any order — this is just determinism, not a +// correctness fix. moduleAliases.sort((a, b) => b.find.length - a.find.length); // Gather every bare specifier a module's pages might import. We include From 026bbc1f762a957cd2090f7e3529ae27a6271165 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 09:34:15 +0200 Subject: [PATCH 11/14] 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 --- framework/cli/tests/test_module_css_assets.py | 20 +++++++++++++++++++ .../hosting/simple_module_hosting/assets.py | 3 +-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/framework/cli/tests/test_module_css_assets.py b/framework/cli/tests/test_module_css_assets.py index a8b00cee..c608dfd1 100644 --- a/framework/cli/tests/test_module_css_assets.py +++ b/framework/cli/tests/test_module_css_assets.py @@ -151,6 +151,26 @@ async def test_module_without_css_emits_no_import(self, tmp_path): assert "@import" not in css + async def test_output_is_formatter_clean(self, tmp_path): + """No doubled blank lines, exactly one trailing newline. + + `biome ci .` lints the whole tree, and `modules.generated.css` is + untracked but not exempt — so a stray blank line here fails `make lint` + for anyone who has run `gen-pages`. + """ + from simple_module_hosting.assets import render_modules_css + + for entry in ( + _assets(tmp_path, styles_css=tmp_path / "gis" / "styles.css"), + _assets(tmp_path, pages_dir=tmp_path / "gis" / "pages"), + ): + css = render_modules_css([entry], in_repo=lambda _p: False) + assert "\n\n\n" not in css, f"doubled blank line in:\n{css!r}" + assert css.endswith("\n") and not css.endswith("\n\n"), repr(css[-20:]) + + empty = render_modules_css([], in_repo=lambda _p: False) + assert "\n\n\n" not in empty and empty.endswith("\n") + async def test_no_relative_paths_anywhere(self, tmp_path): """The whole point of aliases: generated @import lines never use ../..""" from simple_module_core import discover_modules diff --git a/framework/hosting/simple_module_hosting/assets.py b/framework/hosting/simple_module_hosting/assets.py index 83727e02..c2b9e50e 100644 --- a/framework/hosting/simple_module_hosting/assets.py +++ b/framework/hosting/simple_module_hosting/assets.py @@ -94,8 +94,7 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: * 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. - */ -""" + */""" def render_modules_css( From 04df96a3ef008fdae6ae4bc502ba2f7ee0ac809e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 09:41:23 +0200 Subject: [PATCH 12/14] fix(doctor,client): bound CSS strings at newlines; keep tests/ out of @source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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 --- .../templates/host/client_app/styles.css | 4 ++ .../simple_module_core/diagnostics/_css.py | 16 +++++-- framework/core/tests/test_css_diagnostics.py | 42 +++++++++++++++++++ host/client_app/styles.css | 4 ++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/framework/cli/simple_module_cli/templates/host/client_app/styles.css b/framework/cli/simple_module_cli/templates/host/client_app/styles.css index 836ac938..da570968 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/styles.css +++ b/framework/cli/simple_module_cli/templates/host/client_app/styles.css @@ -11,6 +11,10 @@ * keeps .py files out of the scan. */ @source "./**/*.{ts,tsx}"; @source "../../modules/*/*/**/*.{ts,tsx}"; +/* The second `*` above matches any directory under modules//, not just + * the Python package — so a module's sibling tests/ would be scanned too and + * test-only classes would land in the production bundle. */ +@source not "../../modules/*/tests/**"; body { margin: 0; diff --git a/framework/core/simple_module_core/diagnostics/_css.py b/framework/core/simple_module_core/diagnostics/_css.py index 04414c7c..0499fe40 100644 --- a/framework/core/simple_module_core/diagnostics/_css.py +++ b/framework/core/simple_module_core/diagnostics/_css.py @@ -70,7 +70,8 @@ def flush() -> None: if quote is not None: # Inside a string nothing is structural. Escapes are consumed - # whole so a trailing \" doesn't look like the closing quote. + # whole so a trailing \" doesn't look like the closing quote, + # and so an escaped newline continues the string. if ch == "\\" and i + 1 < n: if depth == 0 and buf: buf.append(text[i : i + 2]) @@ -78,12 +79,21 @@ def flush() -> None: line += 1 i += 2 continue + if ch == "\n": + # A CSS string cannot contain a raw newline — an unescaped one + # ends it. Honouring that bounds the blast radius of a stray + # quote to a single line. Without it, one unterminated string + # (a typo, or the lone apostrophe in an unquoted + # url(data:image/svg+xml,...it's...) token) would swallow every + # brace for the rest of the file and silently disable the lint. + quote = None + line += 1 + i += 1 + continue if ch == quote: quote = None if depth == 0 and buf: buf.append(ch) - if ch == "\n": - line += 1 i += 1 continue diff --git a/framework/core/tests/test_css_diagnostics.py b/framework/core/tests/test_css_diagnostics.py index 152a0e98..0ed757cd 100644 --- a/framework/core/tests/test_css_diagnostics.py +++ b/framework/core/tests/test_css_diagnostics.py @@ -186,6 +186,48 @@ def test_single_quoted_strings_handled(self, tmp_path): assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + def test_unterminated_string_does_not_swallow_the_file(self, tmp_path): + """A stray quote must not disable the lint for everything after it. + + A CSS string cannot contain a raw newline, so an unterminated one ends + at end-of-line. Without that bound, a single typo'd quote left the + scanner permanently "inside a string" and every later brace stopped + counting — silently hiding the rest of the file's findings. + """ + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + '.a { color: red; }\n.b {\n content: "unterminated;\n}\n@theme {\n --b: 1;\n}\n' + ) + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:5") + + def test_apostrophe_in_unquoted_url_does_not_swallow_the_file(self, tmp_path): + """An unquoted url() token may hold a lone apostrophe, e.g. inline SVG.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".icon {\n" + " background: url(data:image/svg+xml,it's here);\n" + "}\n" + "@theme {\n" + " --a: 1;\n" + "}\n" + ) + + assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + + def test_escaped_newline_continues_a_string(self, tmp_path): + """A backslash-escaped newline is the one way a string spans lines.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root {\n --a: "x\\\n y";\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + def test_brace_inside_a_comment_is_not_structural(self, tmp_path): """An unbalanced brace in a comment must not shift depth either.""" from simple_module_core.diagnostics._css import check_module_css diff --git a/host/client_app/styles.css b/host/client_app/styles.css index 63499c7e..979b808e 100644 --- a/host/client_app/styles.css +++ b/host/client_app/styles.css @@ -11,3 +11,7 @@ * keeps .py files out of the scan. */ @source "./**/*.{ts,tsx}"; @source "../../modules/*/*/**/*.{ts,tsx}"; +/* The second `*` above matches any directory under modules//, not just + * the Python package — so a module's sibling tests/ would be scanned too and + * test-only classes would land in the production bundle. */ +@source not "../../modules/*/tests/**"; From a65f0cbbcfb2a21a69755e606873d7d8bc9c022e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 09:56:09 +0200 Subject: [PATCH 13/14] fix(doctor): only open a CSS string when its quote closes on the same line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../templates/host/client_app/styles.css | 7 +- .../simple_module_core/diagnostics/_css.py | 71 +++++++++---------- framework/core/tests/test_css_diagnostics.py | 35 +++++++++ host/client_app/styles.css | 7 +- 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/framework/cli/simple_module_cli/templates/host/client_app/styles.css b/framework/cli/simple_module_cli/templates/host/client_app/styles.css index da570968..da9205b1 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/styles.css +++ b/framework/cli/simple_module_cli/templates/host/client_app/styles.css @@ -13,7 +13,12 @@ @source "../../modules/*/*/**/*.{ts,tsx}"; /* The second `*` above matches any directory under modules//, not just * the Python package — so a module's sibling tests/ would be scanned too and - * test-only classes would land in the production bundle. */ + * test-only classes would land in the production bundle. + * + * Consequence: a module's Python package must not itself be named `tests` + * (i.e. modules/tests/tests/), or this line would exclude the package too. No + * glob can tell a package directory from a sibling, and `tests` is not a + * viable package name anyway — it collides with pytest collection. */ @source not "../../modules/*/tests/**"; body { diff --git a/framework/core/simple_module_core/diagnostics/_css.py b/framework/core/simple_module_core/diagnostics/_css.py index 0499fe40..e974c483 100644 --- a/framework/core/simple_module_core/diagnostics/_css.py +++ b/framework/core/simple_module_core/diagnostics/_css.py @@ -50,13 +50,19 @@ def _top_level_preludes(text: str) -> list[tuple[str, int]]: icon-font rule would otherwise desynchronise the depth counter for the whole rest of the file — every later top-level construct would be read as nested and silently skipped. + + A quote only opens a string when its partner is found before the line + ends, which is the rule CSS itself applies (a string cannot contain a raw + newline). Treating every quote as an opener is what makes an *unmatched* + one dangerous: the lone apostrophe in ``url(it's.png); }`` would swallow + the closing brace on that same line and desync the counter permanently. + An unmatched quote is therefore just an ordinary character. """ out: list[tuple[str, int]] = [] depth = 0 buf: list[str] = [] line = 1 buf_line = 1 - quote: str | None = None i = 0 n = len(text) @@ -65,38 +71,26 @@ def flush() -> None: if prelude: out.append((prelude, buf_line)) + def string_end(start: int, quote: str) -> int | None: + """Index just past the closing quote, or None if it never closes.""" + j = start + 1 + while j < n: + c = text[j] + if c == "\\": + # An escape consumes the next character whole — including a + # newline, the one way a CSS string legitimately spans lines. + j += 2 + continue + if c == "\n": + return None + if c == quote: + return j + 1 + j += 1 + return None + while i < n: ch = text[i] - if quote is not None: - # Inside a string nothing is structural. Escapes are consumed - # whole so a trailing \" doesn't look like the closing quote, - # and so an escaped newline continues the string. - if ch == "\\" and i + 1 < n: - if depth == 0 and buf: - buf.append(text[i : i + 2]) - if text[i + 1] == "\n": - line += 1 - i += 2 - continue - if ch == "\n": - # A CSS string cannot contain a raw newline — an unescaped one - # ends it. Honouring that bounds the blast radius of a stray - # quote to a single line. Without it, one unterminated string - # (a typo, or the lone apostrophe in an unquoted - # url(data:image/svg+xml,...it's...) token) would swallow every - # brace for the rest of the file and silently disable the lint. - quote = None - line += 1 - i += 1 - continue - if ch == quote: - quote = None - if depth == 0 and buf: - buf.append(ch) - i += 1 - continue - if ch == "/" and i + 1 < n and text[i + 1] == "*": end = text.find("*/", i + 2) end = n if end == -1 else end + 2 @@ -105,13 +99,16 @@ def flush() -> None: continue if ch in "\"'": - quote = ch - if depth == 0: - if not buf: - buf_line = line - buf.append(ch) - i += 1 - continue + end = string_end(i, ch) + if end is not None: + if depth == 0: + if not buf: + buf_line = line + buf.append(text[i:end]) + line += text.count("\n", i, end) + i = end + continue + # Unmatched: fall through and treat it as an ordinary character. if ch == "{": if depth == 0: diff --git a/framework/core/tests/test_css_diagnostics.py b/framework/core/tests/test_css_diagnostics.py index 0ed757cd..1774fb8c 100644 --- a/framework/core/tests/test_css_diagnostics.py +++ b/framework/core/tests/test_css_diagnostics.py @@ -220,6 +220,41 @@ def test_apostrophe_in_unquoted_url_does_not_swallow_the_file(self, tmp_path): assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + def test_stray_quote_does_not_eat_a_brace_on_its_own_line(self, tmp_path): + """The unmatched-quote case, with the closing brace on the SAME line. + + Bounding strings at newlines was not enough: the `}` here sits on the + stray quote's own line, so it was consumed as string content and the + depth counter stayed desynced for the rest of the file. A quote only + opens a string if its partner appears before the line ends. + """ + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".icon { background: url(it's.png); }\n@theme {\n --a: 1;\n}\n" + ) + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + + def test_multiline_quoted_value_is_not_a_finding(self, tmp_path): + """grid-template-areas spreads several complete strings over lines.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + "@layer components {\n" + " .g {\n" + " grid-template-areas:\n" + ' "a b"\n' + ' "c d";\n' + " }\n" + "}\n" + ) + + assert check_module_css(_mod(), tmp_path) == [] + def test_escaped_newline_continues_a_string(self, tmp_path): """A backslash-escaped newline is the one way a string spans lines.""" from simple_module_core.diagnostics._css import check_module_css diff --git a/host/client_app/styles.css b/host/client_app/styles.css index 979b808e..892140fc 100644 --- a/host/client_app/styles.css +++ b/host/client_app/styles.css @@ -13,5 +13,10 @@ @source "../../modules/*/*/**/*.{ts,tsx}"; /* The second `*` above matches any directory under modules//, not just * the Python package — so a module's sibling tests/ would be scanned too and - * test-only classes would land in the production bundle. */ + * test-only classes would land in the production bundle. + * + * Consequence: a module's Python package must not itself be named `tests` + * (i.e. modules/tests/tests/), or this line would exclude the package too. No + * glob can tell a package directory from a sibling, and `tests` is not a + * viable package name anyway — it collides with pytest collection. */ @source not "../../modules/*/tests/**"; From df75da833adb3dc0bbe69227f8611f892413b93c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 10:18:29 +0200 Subject: [PATCH 14/14] perf(doctor): consume CSS escapes outside strings, and split the scanner tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../simple_module_core/diagnostics/_css.py | 15 ++ framework/core/tests/test_css_diagnostics.py | 187 -------------- framework/core/tests/test_css_scanner.py | 242 ++++++++++++++++++ 3 files changed, 257 insertions(+), 187 deletions(-) create mode 100644 framework/core/tests/test_css_scanner.py diff --git a/framework/core/simple_module_core/diagnostics/_css.py b/framework/core/simple_module_core/diagnostics/_css.py index e974c483..d718bcb8 100644 --- a/framework/core/simple_module_core/diagnostics/_css.py +++ b/framework/core/simple_module_core/diagnostics/_css.py @@ -98,6 +98,21 @@ def string_end(start: int, quote: str) -> int | None: i = end continue + if ch == "\\" and i + 1 < n: + # CSS escapes apply outside strings too — Tailwind leans on this + # heavily (`.mt-\[773px\]`). Consuming the pair keeps an escaped + # quote from being mistaken for a string opener, which is also + # what stops `\'` repeated across a long line from making + # string_end re-scan that line once per quote (quadratic). + if depth == 0: + if not buf: + buf_line = line + buf.append(text[i : i + 2]) + if text[i + 1] == "\n": + line += 1 + i += 2 + continue + if ch in "\"'": end = string_end(i, ch) if end is not None: diff --git a/framework/core/tests/test_css_diagnostics.py b/framework/core/tests/test_css_diagnostics.py index 1774fb8c..2de87213 100644 --- a/framework/core/tests/test_css_diagnostics.py +++ b/framework/core/tests/test_css_diagnostics.py @@ -95,190 +95,3 @@ def test_font_face_allowed(self, tmp_path): ) assert check_module_css(_mod(), tmp_path) == [] - - -class TestScannerRobustness: - def test_nested_at_rule_not_flagged(self, tmp_path): - """Only top-level constructs count — brace depth is tracked.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") - - assert check_module_css(_mod(), tmp_path) == [] - - def test_nested_rule_in_theme_css_not_flagged(self, tmp_path): - """A rule nested inside @theme is not a top-level rule.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n") - - assert check_module_css(_mod(), tmp_path) == [] - - def test_commented_out_construct_is_not_a_finding(self, tmp_path): - """A commented-out @theme is not a finding.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text("/* @theme { --x: 1; } */\n.y { color: red; }\n") - - assert check_module_css(_mod(), tmp_path) == [] - - def test_comment_does_not_shift_line_numbers(self, tmp_path): - """Newlines inside a skipped comment must still be counted.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text("/* a\n multi-line\n comment */\n@theme {\n}\n") - - diags = check_module_css(_mod(), tmp_path) - - assert [d.file.rsplit(":", 1)[1] for d in diags] == ["4"] - - def test_clean_module_has_no_findings(self, tmp_path): - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "theme.css").write_text("@theme {\n --color-x: red;\n}\n") - (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") - - assert check_module_css(_mod(), tmp_path) == [] - - def test_missing_files_are_not_findings(self, tmp_path): - from simple_module_core.diagnostics._css import check_module_css - - assert check_module_css(_mod(), tmp_path) == [] - - def test_open_brace_in_a_string_does_not_hide_later_findings(self, tmp_path): - """A brace inside a string must not desync the depth counter. - - An icon-font rule like `content: "{"` used to leave the scanner - permanently one level deep, so every later top-level construct read as - nested — silently swallowing the very @theme SM022 exists to catch. - """ - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text('.icon { content: "{"; }\n@theme {\n --a: 1;\n}\n') - - diags = check_module_css(_mod(), tmp_path) - - assert [d.code for d in diags] == ["SM022"] - assert diags[0].file.endswith("styles.css:2") - - def test_close_brace_in_a_string_is_not_a_finding(self, tmp_path): - """A closing brace inside a string must not end the block early.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "theme.css").write_text(':root {\n --icon-close: "}";\n}\n') - - assert check_module_css(_mod(), tmp_path) == [] - - def test_escaped_quote_in_a_string(self, tmp_path): - """A backslash-escaped quote does not terminate the string.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "theme.css").write_text(':root {\n --q: "a\\"{b";\n}\n') - - assert check_module_css(_mod(), tmp_path) == [] - - def test_single_quoted_strings_handled(self, tmp_path): - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text( - ".icon { content: '{'; }\n@utility tab-4 {\n tab-size: 4;\n}\n" - ) - - assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] - - def test_unterminated_string_does_not_swallow_the_file(self, tmp_path): - """A stray quote must not disable the lint for everything after it. - - A CSS string cannot contain a raw newline, so an unterminated one ends - at end-of-line. Without that bound, a single typo'd quote left the - scanner permanently "inside a string" and every later brace stopped - counting — silently hiding the rest of the file's findings. - """ - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text( - '.a { color: red; }\n.b {\n content: "unterminated;\n}\n@theme {\n --b: 1;\n}\n' - ) - - diags = check_module_css(_mod(), tmp_path) - - assert [d.code for d in diags] == ["SM022"] - assert diags[0].file.endswith("styles.css:5") - - def test_apostrophe_in_unquoted_url_does_not_swallow_the_file(self, tmp_path): - """An unquoted url() token may hold a lone apostrophe, e.g. inline SVG.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text( - ".icon {\n" - " background: url(data:image/svg+xml,it's here);\n" - "}\n" - "@theme {\n" - " --a: 1;\n" - "}\n" - ) - - assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] - - def test_stray_quote_does_not_eat_a_brace_on_its_own_line(self, tmp_path): - """The unmatched-quote case, with the closing brace on the SAME line. - - Bounding strings at newlines was not enough: the `}` here sits on the - stray quote's own line, so it was consumed as string content and the - depth counter stayed desynced for the rest of the file. A quote only - opens a string if its partner appears before the line ends. - """ - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text( - ".icon { background: url(it's.png); }\n@theme {\n --a: 1;\n}\n" - ) - - diags = check_module_css(_mod(), tmp_path) - - assert [d.code for d in diags] == ["SM022"] - assert diags[0].file.endswith("styles.css:2") - - def test_multiline_quoted_value_is_not_a_finding(self, tmp_path): - """grid-template-areas spreads several complete strings over lines.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text( - "@layer components {\n" - " .g {\n" - " grid-template-areas:\n" - ' "a b"\n' - ' "c d";\n' - " }\n" - "}\n" - ) - - assert check_module_css(_mod(), tmp_path) == [] - - def test_escaped_newline_continues_a_string(self, tmp_path): - """A backslash-escaped newline is the one way a string spans lines.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "theme.css").write_text(':root {\n --a: "x\\\n y";\n}\n') - - assert check_module_css(_mod(), tmp_path) == [] - - def test_brace_inside_a_comment_is_not_structural(self, tmp_path): - """An unbalanced brace in a comment must not shift depth either.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "styles.css").write_text("/* } { */\n@theme {\n --a: 1;\n}\n") - - diags = check_module_css(_mod(), tmp_path) - - assert [d.code for d in diags] == ["SM022"] - assert diags[0].file.endswith("styles.css:2") - - def test_import_statement_allowed_in_either_file(self, tmp_path): - """A statement at-rule ending in ; is not a rule block.""" - from simple_module_core.diagnostics._css import check_module_css - - (tmp_path / "theme.css").write_text('@import "other.css";\n') - (tmp_path / "styles.css").write_text('@charset "utf-8";\n') - - assert check_module_css(_mod(), tmp_path) == [] diff --git a/framework/core/tests/test_css_scanner.py b/framework/core/tests/test_css_scanner.py new file mode 100644 index 00000000..52ddc760 --- /dev/null +++ b/framework/core/tests/test_css_scanner.py @@ -0,0 +1,242 @@ +"""The CSS scanner behind SM022/SM023. + +`check_module_css` is only as good as its ability to find *top-level* +constructs, and it does that with a hand-rolled brace-depth scan rather than a +CSS parse — no parser joins the runtime dependencies to power a lint. These +tests pin the tokenising rules that scan depends on: comments, quoted strings, +escapes, and the line numbers reported alongside a finding. + +Every case here is a bug that shipped at some point during review: each one is +a way a stray character silently desynced the depth counter and made the lint +stop reporting anything at all. +""" + +from __future__ import annotations + + +def _mod(): + from simple_module_core import ModuleBase, ModuleMeta + + class Styled(ModuleBase): + meta = ModuleMeta(name="Styled") + + return Styled() + + +class TestScannerRobustness: + def test_nested_at_rule_not_flagged(self, tmp_path): + """Only top-level constructs count — brace depth is tracked.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_nested_rule_in_theme_css_not_flagged(self, tmp_path): + """A rule nested inside @theme is not a top-level rule.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_commented_out_construct_is_not_a_finding(self, tmp_path): + """A commented-out @theme is not a finding.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* @theme { --x: 1; } */\n.y { color: red; }\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_comment_does_not_shift_line_numbers(self, tmp_path): + """Newlines inside a skipped comment must still be counted.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* a\n multi-line\n comment */\n@theme {\n}\n") + + diags = check_module_css(_mod(), tmp_path) + + assert [d.file.rsplit(":", 1)[1] for d in diags] == ["4"] + + def test_clean_module_has_no_findings(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text("@theme {\n --color-x: red;\n}\n") + (tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n") + + assert check_module_css(_mod(), tmp_path) == [] + + def test_missing_files_are_not_findings(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + assert check_module_css(_mod(), tmp_path) == [] + + def test_open_brace_in_a_string_does_not_hide_later_findings(self, tmp_path): + """A brace inside a string must not desync the depth counter. + + An icon-font rule like `content: "{"` used to leave the scanner + permanently one level deep, so every later top-level construct read as + nested — silently swallowing the very @theme SM022 exists to catch. + """ + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text('.icon { content: "{"; }\n@theme {\n --a: 1;\n}\n') + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + + def test_close_brace_in_a_string_is_not_a_finding(self, tmp_path): + """A closing brace inside a string must not end the block early.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root {\n --icon-close: "}";\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + + def test_escaped_quote_in_a_string(self, tmp_path): + """A backslash-escaped quote does not terminate the string.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root {\n --q: "a\\"{b";\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + + def test_single_quoted_strings_handled(self, tmp_path): + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".icon { content: '{'; }\n@utility tab-4 {\n tab-size: 4;\n}\n" + ) + + assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + + def test_unterminated_string_does_not_swallow_the_file(self, tmp_path): + """A stray quote must not disable the lint for everything after it. + + A CSS string cannot contain a raw newline, so an unterminated one ends + at end-of-line. Without that bound, a single typo'd quote left the + scanner permanently "inside a string" and every later brace stopped + counting — silently hiding the rest of the file's findings. + """ + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + '.a { color: red; }\n.b {\n content: "unterminated;\n}\n@theme {\n --b: 1;\n}\n' + ) + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:5") + + def test_apostrophe_in_unquoted_url_does_not_swallow_the_file(self, tmp_path): + """An unquoted url() token may hold a lone apostrophe, e.g. inline SVG.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".icon {\n" + " background: url(data:image/svg+xml,it's here);\n" + "}\n" + "@theme {\n" + " --a: 1;\n" + "}\n" + ) + + assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"] + + def test_stray_quote_does_not_eat_a_brace_on_its_own_line(self, tmp_path): + """The unmatched-quote case, with the closing brace on the SAME line. + + Bounding strings at newlines was not enough: the `}` here sits on the + stray quote's own line, so it was consumed as string content and the + depth counter stayed desynced for the rest of the file. A quote only + opens a string if its partner appears before the line ends. + """ + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".icon { background: url(it's.png); }\n@theme {\n --a: 1;\n}\n" + ) + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + + def test_multiline_quoted_value_is_not_a_finding(self, tmp_path): + """grid-template-areas spreads several complete strings over lines.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + "@layer components {\n" + " .g {\n" + " grid-template-areas:\n" + ' "a b"\n' + ' "c d";\n' + " }\n" + "}\n" + ) + + assert check_module_css(_mod(), tmp_path) == [] + + def test_escaped_selector_is_not_a_string_opener(self, tmp_path): + """CSS escapes apply outside strings — Tailwind selectors rely on it.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text( + ".mt-\\[773px\\] { margin-top: 773px; }\n@theme {\n --a: 1;\n}\n" + ) + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + + def test_scanner_is_linear_on_pathological_input(self, tmp_path): + """A long run of escaped quotes must not make the scan quadratic. + + `doctor` runs this over every installed module, including third-party + ones, so a minified or vendored stylesheet must not stall the lint. + Before escapes were consumed outside strings, 58KB of `\\'` took ~10s. + """ + import time + + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("\\'" * 60_000) + + started = time.perf_counter() + check_module_css(_mod(), tmp_path) + elapsed = time.perf_counter() - started + + assert elapsed < 2.0, f"scan took {elapsed:.1f}s — likely quadratic again" + + def test_escaped_newline_continues_a_string(self, tmp_path): + """A backslash-escaped newline is the one way a string spans lines.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text(':root {\n --a: "x\\\n y";\n}\n') + + assert check_module_css(_mod(), tmp_path) == [] + + def test_brace_inside_a_comment_is_not_structural(self, tmp_path): + """An unbalanced brace in a comment must not shift depth either.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "styles.css").write_text("/* } { */\n@theme {\n --a: 1;\n}\n") + + diags = check_module_css(_mod(), tmp_path) + + assert [d.code for d in diags] == ["SM022"] + assert diags[0].file.endswith("styles.css:2") + + def test_import_statement_allowed_in_either_file(self, tmp_path): + """A statement at-rule ending in ; is not a rule block.""" + from simple_module_core.diagnostics._css import check_module_css + + (tmp_path / "theme.css").write_text('@import "other.css";\n') + (tmp_path / "styles.css").write_text('@charset "utf-8";\n') + + assert check_module_css(_mod(), tmp_path) == []