diff --git a/.changeset/vite-plugin-weft-css.md b/.changeset/vite-plugin-weft-css.md
new file mode 100644
index 0000000..3dbf2bc
--- /dev/null
+++ b/.changeset/vite-plugin-weft-css.md
@@ -0,0 +1,11 @@
+---
+'@e-cline/weft_vite_plugin': patch
+---
+
+`weft_css` styles: the plugin extracts each style file's stylesheet (running it in a module runner
+of its own), strips the definitions from every bundle, and serves each sheet as a CSS module.
+`weft()` puts the sheets in `virtual:weft/assets` for `weft_server`; `weftCss()` is the plugin for
+an app without a server, whose modules import their sheets. In development an edit to a style
+changes the stylesheet in place, with no reload. The test harness applies the head's stylesheets,
+loads the others a task later (`holdStylesheets`, `releaseStylesheets`), reports what shows without
+its CSS (`unstyled`), and names elements by `data-id` when they have no id.
diff --git a/.changeset/weft-css.md b/.changeset/weft-css.md
new file mode 100644
index 0000000..e5d17c0
--- /dev/null
+++ b/.changeset/weft-css.md
@@ -0,0 +1,12 @@
+---
+'@e-cline/weft_css': patch
+---
+
+A new package: styles declared in ReScript, extracted to CSS at build time. `Css.class`, `Css.var`,
+`Css.keyframes` and `Css.global`, at a module's top level, take a typed record of properties (with
+nested blocks, `selectors` and `media`); `ppx_weft` names each from its module and binding, and
+`weft_vite_plugin` extracts each file's styles into a stylesheet of its own, leaving only the names
+in the JavaScript. Classes are in the cascade layer `weft.components`, globals and variables in
+`weft.base`. `Css.vars` sets variables in a `style` prop, for what changes at run time; `Css.cx`
+joins class names. `Sheets.load` and `Sheets.isLoaded` load a stylesheet once per document, sharing
+their state with the page's inline runtime.
diff --git a/.changeset/weft-dom-stylesheets.md b/.changeset/weft-dom-stylesheets.md
new file mode 100644
index 0000000..7b26a93
--- /dev/null
+++ b/.changeset/weft-dom-stylesheets.md
@@ -0,0 +1,7 @@
+---
+'@e-cline/weft_dom': patch
+---
+
+`Html.render ~stylesheetsOf`: a streamed segment's swap waits for the stylesheets its markup needs.
+A segment inside one still waiting runs once that one is in. A new `dataId` prop writes `data-id`,
+for tests to find an element by.
diff --git a/.changeset/weft-server-stylesheets.md b/.changeset/weft-server-stylesheets.md
new file mode 100644
index 0000000..bb4c860
--- /dev/null
+++ b/.changeset/weft-server-stylesheets.md
@@ -0,0 +1,11 @@
+---
+'@e-cline/weft_server': patch
+---
+
+Stylesheets: given the app's (`Page.assets`' `sheets` and `entrypointSheets`, from
+`virtual:weft/assets`), `Render.toStream ~styles` sends each row the stylesheets its class names
+need (`C` rows, before the row), and the decoder waits for them before a row's content shows
+(`Decoder.make ~loadSheet`). A first page links the shell's stylesheets at the end of its head, held
+until the shell is ready, preloads the ones the rest of the page needs, and each streamed segment
+waits for its own before it's swapped in. Navigations and refetches keep the old content until the
+new one's stylesheets have loaded.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 47e6515..9c8260b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,7 +54,7 @@ jobs:
e2e:
# The browser tests (examples/weft_notes/e2e and examples/weft_shop/e2e), against the built apps
- # in Chromium, on one Node version. Everything a browser isn't needed for is tested in the job
+ # in Chromium (and weft_shop's styles test in Firefox and WebKit too), on one Node version. Everything a browser isn't needed for is tested in the job
# above.
name: Browser tests
runs-on: ubuntu-latest
@@ -76,9 +76,9 @@ jobs:
- run: vp pack
working-directory: tools/weft_vite_plugin
- run: vp run build
- - name: Install Chromium for Playwright
+ - name: Install Chromium, Firefox and WebKit for Playwright
run:
pnpm --filter @e-cline/weft_notes_example exec playwright install
- --with-deps chromium
+ --with-deps chromium firefox webkit
- run: vp run --filter @e-cline/weft_notes_example e2e
- run: vp run --filter @e-cline/weft_shop_example e2e
diff --git a/README.md b/README.md
index 2040479..b998cc3 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ WARNING: None of this is intended for real production use.
Weft is the UI framework in here: `weft` (components, hooks and a fiber reconciler), `weft_dom` (the
DOM renderer) and `weft_signals` (hooks for reading `signals`), plus the `weft_app` example. Server
components for it are under way: `weft_server` and `weft_vite_plugin`, with the `weft_notes`,
-`weft_split` and `weft_shop` examples.
+`weft_split` and `weft_shop` examples. So is CSS: `weft_css` ([its plan](docs/weft_css_plan.md)).
## Packages
@@ -24,6 +24,7 @@ components for it are under way: `weft_server` and `weft_vite_plugin`, with the
| [`@e-cline/weft`](packages/weft) | A React-like component library: `@weft.component` components and hooks, rendered by a fiber reconciler through a pluggable host renderer. |
| [`@e-cline/weft_dom`](packages/weft_dom) | The DOM renderer for `weft`: render components into a browser page. |
| [`@e-cline/weft_signals`](packages/weft_signals) | Hooks for reading `signals` atoms and computeds from `weft` components. |
+| [`@e-cline/weft_css`](packages/weft_css) | Styles written in ReScript beside their components, extracted into CSS at build time, and each page sent only the CSS of what it renders. |
| [`@e-cline/weft_server`](packages/weft_server) | Server components for Weft: render on the server into a streamed payload, decode it on the client, with entrypoints loaded on demand. |
| [`@e-cline/weft_vite_plugin`](tools/weft_vite_plugin) | The Vite plugin for Weft's server components: server and client environments, an entrypoint per chunk, the boundary enforced, dev and build. |
diff --git a/docs/weft_css_plan.md b/docs/weft_css_plan.md
new file mode 100644
index 0000000..837f549
--- /dev/null
+++ b/docs/weft_css_plan.md
@@ -0,0 +1,927 @@
+# Plan: CSS for Weft
+
+Styles written in ReScript, next to the components that use them (or in modules of their own, for
+shared ones), extracted into ordinary CSS files at build time, and sent to the browser only when
+something on the page uses them, with no flash of unstyled content: for client components, for
+server components, for server-rendered HTML streamed in pieces, and for pages and parts of pages
+fetched on navigation.
+
+```rescript
+// src/client/AddToCart.res: an entrypoint
+@@jsxConfig({version: 4, module_: "WeftDom.Jsx"})
+open WeftCss
+
+module Styles = {
+ let button = Css.class({
+ display: InlineFlex,
+ gap: Rem(0.5),
+ padding: Px(8),
+ color: Var(Theme.onAccent),
+ background: Var(Theme.accent),
+ borderRadius: Px(6),
+ hover: Css.style({background: Var(Theme.accentHover)}),
+ disabled: Css.style({opacity: 0.5}),
+ })
+}
+
+@weft.component({kind: Entrypoint})
+let make = (~sku: string) =>
+
+```
+
+```rescript
+// src/server/ProductPage.res: a server component, whose styles never reach the client as code
+@@jsxConfig({version: 4, module_: "WeftDom.Jsx"})
+open WeftCss
+
+let page = Css.class({display: Grid, gridTemplateColumns: Raw("2fr 1fr"), gap: Rem(2.0)})
+let price = Css.class({fontSize: Rem(1.5), fontVariantNumeric: TabularNums})
+
+@weft.component({kind: Server})
+let make = async (~id) => {
+ let product = await Db.product(id)
+
+
+
{WeftDom.Jsx.string(Money.format(product.price))}
+
+
+}
+```
+
+`ProductPage`'s CSS reaches the browser because the page rendered `page` and `price`, and only then;
+`AddToCart`'s, because the browser loads `AddToCart`'s code. Neither is shown before its CSS has
+loaded.
+
+## Status
+
+Every phase, 0 to 7, is done, on the branch `claude/weft-css-support-c3db73`, a commit each (one PR
+for all of them). The decisions were taken as recommended (Decisions). The design sections are as
+first written, each followed by "As built" notes where the implementation went its own way or
+settled what the design left open: phase 0's spikes under "Phase 0: what the spikes found" (after
+Phases), then phase 1's after §2, phase 2's after §3, phases 3 and 4's after §4, phase 5's after §6,
+phase 6's after §7, and phase 7's after §8. What's left to do is under Follow-ups, at the end.
+
+The things most worth knowing, for whoever works on this next:
+
+- **Phase 3 took `Render`'s scan from phase 4**: the head can only link the shell's sheets if the
+ server knows them. An entrypoint's sheets go on the row that uses it, not its `E` row.
+- **A segment's sheets are its markup's**, found by `Page` in the HTML, not "every sheet since the
+ shell" (phase 5).
+- **The server's decoder never waits for a sheet**, and the harness starts every page with no
+ stylesheet loaded and counts a swap that happens late (after its sheets load) as the HTML's: each
+ found when a jsdom test put the server and the client in one process.
+- **ReScript writes a copy of each compiled file in its `lib/`**, whose change arrives with the real
+ one's: anything the plugin decides per change has to be kept per file (phase 6).
+
+## What was checked before writing this
+
+Against the code as `main` has it (after `weft_shop`, PR 14), and the sources named:
+
+1. **Weft has no CSS story yet.** The plugin's `virtual:weft/assets` has `styles` (the stylesheets
+ the bootstrap's chunks import, from the client manifest), and `Page` writes them as
+ `` in the head; entrypoint files' CSS isn't carried at all (`buildAssets`
+ keeps only `closure(...).files` for them, not `css`). Both plans list CSS beyond what Vite does
+ as out of scope. `weft_shop` puts a 100-line stylesheet, as a string, in every page's head
+ (`examples/weft_shop/src/server/Styles.res`, through `Page.respond(~head)`).
+2. **`rescript-css` (`@jvlk/rescript-css` 0.2.0) can't be used as it is**, though its API is a good
+ one to follow (read at its current `main`):
+ - Its Vite plugin **rewrites the compiled `.res.js` files on disk** (at `configResolved` and
+ again in `transform`): it replaces each style call with its class name and adds
+ `import "./Button.css"` at the top, writing the `.css` file beside it. Here the compiled output
+ is committed and a clean build has to reproduce it exactly (the server components plan's
+ "Things learned"), so a plugin that edits it is a non-starter.
+ - It finds what to replace with **regular expressions over the compiled JavaScript**
+ (`let Styles = {...}` patterns, `export {...}` lists), which is tied to the compiler's output
+ format; Weft's plugin reads only directives the PPX writes, for that reason.
+ - It collects CSS by **`import()`ing every compiled module of the app in Node**, with a collector
+ on `globalThis`; class names are `rc__`, numbered in the order the
+ module's calls run. The runtime and the extraction both depend on that global collector (nested
+ `hover: Css.style(...)` registers a rule in it too), so its value layer can't be reused without
+ its runtime.
+ - **Its CSS is attached to modules by side-effect imports**, which is what makes it work with
+ Vite's CSS code-splitting, and also what makes it not work for server components: a server
+ component's file is never in the client's module graph, so its stylesheet is never in the
+ client build, and nothing tells the page it's needed.
+ - It needs Node ≥ 24.12 (the workspace allows ≥ 22).
+3. **React 19's stylesheet resources** (react.dev, ``): a `` with a
+ `precedence` is hoisted into the head, de-duplicated by `href`, ordered by precedence (groups in
+ the order first seen, later groups winning), and **the component rendering it suspends until it
+ has loaded**, so the nearest `Suspense` boundary keeps its fallback (or, in a transition, the old
+ content stays) until the CSS is there. In streaming SSR (from Fizz's source as I know it, not
+ re-read for this plan), Fizz holds a boundary's reveal until the stylesheets it rendered have
+ loaded (its completion script, `$RR`, inserts them and waits), rather than blocking the parser.
+ Server components reach it with `ReactDOM.preinit(href, {as: "style", precedence})`, which Flight
+ sends as a hint row.
+4. **`@vitejs/plugin-rsc`** (its README): CSS imported by a server component's module, directly or
+ not, is collected with `import.meta.viteRsc.loadCss(importer?)`, a React node of
+ ``s; by default the plugin wraps every capitalized exported function of a server
+ module that imports CSS in it (`rscCssTransform`, with a `filter` to opt out). A client
+ reference's CSS comes with it (`onClientReference` sees `deps: {js, css}`). The unit is the
+ **module graph**: what's imported, not what's rendered.
+5. **Next.js's App Router** (from its behaviour as I know it; its docs didn't say, and this is worth
+ confirming against its source if it matters) works the same way, per route segment: the CSS of a
+ layout's or page's module graph (server and client components) is listed in the RSC payload for
+ that segment and rendered as React stylesheet resources (`precedence="next"`), so a client
+ navigation's new segments suspend until their CSS has loaded. **TanStack Start** (its CSS guide)
+ puts the CSS of the matched routes' modules in its manifest, and notes that CSS imported from an
+ async component isn't in the initial SSR links. **Remix/React Router, SvelteKit, SolidStart and
+ Astro** are route- or page-graph based too; SvelteKit and Astro inline stylesheets under a size
+ threshold.
+6. **Compile-time CSS-in-JS**: vanilla-extract evaluates `.css.ts` files at build time (in a module
+ runner) and turns them into CSS imports plus class-name exports; Linaria (wyw-in-js) evaluates
+ only the slice of a file its styles depend on; StyleX and Panda extract statically and go atomic,
+ one stylesheet for the whole app, whose size grows with the number of distinct declarations
+ rather than components. All of them, again, are module-graph or whole-app. **Qwik** is the one
+ that goes by what renders: a component's `useStyles$` registers its styles when it renders, and
+ SSR writes only the rendered components' styles into the page.
+
+So nothing here can be reused as it is. Every Vite-based option ties CSS to modules, which is right
+for client components (their code is loaded, so their CSS might as well be) and wrong for this
+plan's main case: server components whose output is dynamic (a CMS's blocks, a search's results),
+where a module-graph rule sends the CSS for every block type the page _could_ render. What can be
+reused is the shape of the answers: rescript-css's API, vanilla-extract's build-time evaluation,
+React's "a boundary doesn't reveal until its stylesheets have loaded", and Qwik's "send what
+rendered".
+
+## How it works, end to end
+
+```
+compile Button.res ──ppx_weft──► Button.res.js: Css.class(...) calls become
+ Internal.class_("Button-button_wk3f9a", () => {...})
+ + 'use weft css' directive
+
+vite scan for 'use weft css' ──► evaluate each file in a CSS module runner (Node), calling
+ the definitions ──► one sheet per file: wk3f9a.css
+ transform (client and rsc) ──► the definitions are stripped: only the names are left
+ build ──► each sheet a hashed asset; the manifest: sheet id → URL, and for each
+ entrypoint file the sheets its client module graph imports
+
+request Render walks the tree ──► host className props and strings in entrypoint props are
+ matched against sheet ids ──► a C row per sheet, first use;
+ J rows name the C rows they need; E rows bring their own sheets
+ Page ──► head held open until the shell is ready, then the shell's sheets as links
+ in the head; later sheets preloaded as they come; a segment's swap waits for
+ its sheets to load
+
+browser Decoder ──► a C row loads its sheet (a in the head, once per href);
+ a J row waits for its sheets as it waits for its entrypoints
+ navigation/refetch: new rows render in a transition, so nothing shows unstyled
+```
+
+Two ways for a class name to reach the DOM, and one rule for each:
+
+- **From the server**, in the payload: a server or shared component's output, or a string handed to
+ an entrypoint as a prop. The server sees it as it writes the payload, and sends the sheet with the
+ rows that use it: CSS by **what rendered**.
+- **From client code**: an entrypoint (or anything it imports) rendering one of its own classes,
+ perhaps only after a click. That code is in the browser anyway, and its sheets come with it: CSS
+ by **what's loaded**, as every Vite framework does, but per style file rather than per chunk.
+
+Class names are constants the PPX writes (styles are static: dynamic values go through CSS
+variables, §2), so there's no third way, short of building one by hand from a string.
+
+## Design
+
+### 1. The package: `weft_css`
+
+A new ReScript package, `@e-cline/weft_css`, namespace `WeftCss`, depending on nothing but
+`@rescript/runtime`. Its main module is `Css`, so code reads `Css.class(...)` after `open WeftCss`.
+The API follows rescript-css's, so its documentation mostly applies and code ports with few changes:
+
+| Call | Gives | CSS |
+| ------------------------------------------------- | -------------- | ----------------------------------------------------------- |
+| `Css.class(css)` | a class name | `.name { ... }` and its nested rules |
+| `Css.style(css)` | a `Css.t` | none by itself: a nested block, or a fragment to reuse |
+| `Css.global(~selector, css)` | `unit` | `selector { ... }` |
+| `Css.var(initial)` | a `Css.var` | `--name: initial` on `:root`, in the declaring file's sheet |
+| `Css.keyframes([Css.frame(~at, css), ...])` | a name | `@keyframes name { ... }` |
+| `Css.fontFace({...})` (later) | a family name | `@font-face { ... }` |
+| `Css.media(~query, css)`, `supports`, `container` | a condition | nested in the class's rule |
+| `Css.layerOrder([...])`, `~layer=` on the above | | `@layer` (§5) |
+| `Css.vars([(var, value)])` | a `style` dict | none: custom properties for `style=`, at run time |
+| `Css.cx([a, b])`, `Css.cxOpt([Some(a), None])` | a class list | none |
+
+`css` is a record of optional fields, one per property, typed where it's worth it (`display`,
+lengths, colours, `Var(v)`) and with `Raw(string)` everywhere as the way out; nested selectors are
+fields too (`hover`, `focusVisible`, `disabled`, element names like `h2`,
+`selectors: [("&[data-open]", Css.style(...))]`, `media: [...]`). Phase 1 starts with the properties
+`weft_shop` uses, and grows; the property set is the bulk of the work and the least risky part
+(rescript-css's `CssValue` is 2,000 lines of it), so it's done incrementally, with `Raw` covering
+what's missing.
+
+`Css.style` is `external style: t => t = "%identity"`, so a fragment (`let base = Css.style({...})`)
+compiles to a plain object literal, which bundles can drop once nothing uses it (an ordinary
+function would make it a call, and its module impure). `Css.var` gives an abstract `Css.var`, its
+name, which `Var(v)` serializes as `var(name)`.
+
+**Styles are static.** `Css.class`, `global`, `var`, `keyframes` and `fontFace` are allowed only in
+`let` bindings at a module's top level (in any nesting of modules), and the PPX rejects them
+anywhere else, with an error saying to use a CSS variable (`Css.vars`), a data attribute and a
+`selectors` rule, or one class per variant. That's what lets every class have a name at compile
+time, and every sheet be a file.
+
+**Without the PPX**, `Css.class` and the others throw when called ("weft_css: this file wasn't
+compiled with ppx_weft"). They're called at module level, so the first import fails, at once; and
+the Vite plugin checks `ppx-flags`, as it already does for `@weft.component`.
+
+### 2. The PPX (`ppx_weft`)
+
+`ppx_weft` gains the style calls. In a file that has any:
+
+```rescript
+// src/client/AddToCart.res, written
+module Styles = {
+ let button = Css.class({display: InlineFlex, hover: Css.style({background: "#115e59"})})
+}
+
+// what the compiler then sees
+@@directive("'use weft css'")
+module Styles = {
+ let button = Css.Internal.class_("AddToCart-Styles-button_wk3f9a", () => {
+ display: InlineFlex,
+ hover: Css.style({background: "#115e59"}),
+ })
+}
+```
+
+which bsc compiles, every time, to the shape the plugin rewrites (§3):
+
+```js
+'use weft css';
+let button = Css$WeftCss.Internal.class_('AddToCart-Styles-button_wk3f9a', () => ({
+ display: 'InlineFlex',
+ hover: {background: '#115e59'},
+}));
+```
+
+- **The name** is readable, then the file's **sheet id**: `w` and six base-36 digits of a hash
+ (32-bit FNV-1a, modulo 36⁶) of the file's id
+ (`/`, the same id entrypoints use, so it's the
+ same in every checkout). The plugin checks that no two files' sheet ids collide. The readable part
+ is the file's module name and the binding's path, made into a CSS identifier; it's unique within
+ the file, since bindings at one path are. Compiled output is committed and the same in development
+ and production, so names can't be shortened for production by the PPX; the plugin could rewrite
+ them later (Follow-ups).
+- **The definition becomes a thunk.** At run time, in the browser and on the server, `class_` just
+ returns the name: definitions are never evaluated, so nothing of `Css`'s value layer runs or, once
+ the plugin strips the thunks (§3), is bundled. Extraction calls them.
+- `var`, `keyframes` and `fontFace` get names the same way (`--Theme-accent_wk3f9a`), and `global`
+ an index in the file. A value naming another file's var or keyframes (`Var(Theme.accent)`) is a
+ string constant by then, so evaluating one file's definitions needs the other file's _names_, not
+ its definitions: the other file is imported as any module is, but its thunks aren't called.
+- The call is recognized by its path's last two parts (`Css.class`, `WeftCss.Css.class`); a module
+ alias (`module C = Css`) isn't followed, and gets the run-time error above. The PPX is untyped, so
+ that's the best it can do, and it fails loudly rather than silently.
+
+- A style call anywhere else (in a function, a conditional, a nested `Css.class` inside a
+ definition) is a compile error at the call, naming the alternatives (§1).
+
+`'use weft css'` is how the plugin finds files with styles, as `'use weft entrypoint'` is for
+entrypoints (§3). A file can be any kind (server, entrypoint, shared) and have styles too, so the
+directive goes alongside whichever of the others the file gets (the spike only handled a file with
+neither).
+
+**As built (phase 1).**
+
+- **Keywords are `@unboxed` variants** whose constructors are `@as` their CSS keyword, with a
+ `Raw(string)` catch-all (`InlineFlex` is the string `"inline-flex"` at run time), so serializing
+ one is the identity, and constructor names needn't kebab-case into the keyword (`Nowrap` for
+ `nowrap`). An unboxed variant can only have one string payload, so `display` has no `Var`:
+ `Raw("var(...)")` does it. Lengths and colours are ordinary variants, serialized by a `switch`.
+- **The serializer is explicit**, a line per property in the order `Css.t` declares its fields
+ (`Serialize.declarations`), rather than walking the runtime object, whose key order a spread
+ changes. Selectors nest as rescript-css's do (`&` is the parent, no `&` is a descendant, lists are
+ crossed); at-rules wrap their block's rules.
+- **`Extract` groups registrations by the sheet id their names end in**, so the collector is one
+ list for everything that ran while recording (a file imported by another records once, whoever
+ imports it first). Per sheet: variables on `:root` and globals in `weft.base`, classes and
+ keyframes in `weft.components`, any other `~layer` after those, each in declaration order; and the
+ sheet's dependencies, from the other sheet ids in its text. It throws for a definition that throws
+ (naming it), a name registered twice, and a name with no sheet id.
+- **What the PPX writes** compiles positionally: `Internal.class_(name, layer, () => ...)` and
+ `Internal.global_(name, selector, layer, () => ...)`, with `undefined` for an absent layer.
+ Globals are named `-global`. A nested style call is checked for before anything else,
+ so its error is the "static" one even in a file with no `rescript.json`; the missing
+ `rescript.json` error says `Css.class:` rather than `@weft.component:`.
+- **The hash, twice**: OCaml hashes the file id's bytes, so `SheetId` hashes its UTF-8 encoding
+ (`TextEncoder`), not UTF-16 code units; a fixture under `fixtures/größe/` pins the two together.
+ bsc compiles `x >>> 0` to `x` (a no-op in its int model), so the unsigned value is made in floats.
+- **Left for later**: `Css.fontFace` and `registerProperty` (nothing uses them yet), and
+ `Css.layerOrder`, which is the page's to declare (phase 3).
+
+### 3. Extraction (the Vite plugin)
+
+The `weft` plugin gains the CSS work. (Rather than a separate plugin: delivery needs the payload and
+the manifest, which are `weft`'s. A client-only app gets a smaller `weftCss()` export, §8.)
+
+**Finding style files.** The scan that finds entrypoint files also collects files with
+`'use weft css'`, from the app's sources and its ReScript dependencies' (so a component library's
+styles work), and computes each one's file id and sheet id, checking them against the names in its
+`class_` calls.
+
+**Evaluating them.** A **CSS module runner**, a Vite environment of the plugin's own (`weft_css`:
+environment names may only have letters, digits, `$` and `_`; Node, resolving as the client does,
+conditions included, so `Common.Env.isDev` is the same), imports each style file with a collector
+installed; each `class_`, `global` and so on records its name and thunk, and the plugin calls the
+thunks and serializes the results, file by file, into CSS text. One **sheet per style file**, with
+its globals, vars, keyframes and font faces, and its classes in the order they're declared.
+
+Evaluating a file runs its top level and its imports' (not their thunks). That's already a
+requirement of a server-rendered Weft app: server files run in `rsc`, and every entrypoint file,
+with what it imports, is imported on the server for SSR (`virtual:weft/ssr-references`). Client
+modules that touch `window` at load time would break both; Weft's own don't (`WeftDom.Jsx` loads
+without a DOM). The runner is separate from `rsc` so extraction never shares state with request
+handling, and runs in builds as well as in development (`createRunnableDevEnvironment`, as the
+plugin's dev server does today). In the spike, every one of `weft_shop`'s and `weft_notes`'
+component files, server and client, evaluated in such a runner with no DOM (45 files in about 250
+ms). What failed were **entry modules**: the server entry (it imports `virtual:weft/assets`) and
+`weft_app`'s `Main` (it renders into `document` when loaded). The runner only imports style files
+(and what they import), so an entry module matters only if it has styles itself; extraction then
+fails with the file and the error, saying to move the styles to a module of their own. Slicing files
+down to what their styles need, as wyw-in-js does, is the fallback if top-level side effects turn
+out to be a problem in practice (Risks).
+
+**Stripping.** In every environment's `transform`, a style file's `class_(name, thunk)` calls are
+replaced by the name itself, a string literal, using Vite's parser (the calls are the PPX's, in a
+fixed shape, but the plugin uses a real AST, not patterns), and the directive is blanked as the
+others are. With no calls left, the module's top level is pure, and bundles tree-shake `Css`'s value
+layer away; the server doesn't need it either.
+
+**Sheets as files.** Each sheet is a CSS module whose content the plugin supplies, imported with
+`?url` (`virtual:weft/css/.css?url`), which runs it through Vite's CSS pipeline (PostCSS
+or Lightning CSS, minification and prefixing, `url()` assets) and, in a build, emits it as a hashed
+asset of its own, with a manifest entry: not merged into chunks' CSS, because the same sheet is
+needed from two directions (§4) and must be one URL to be loaded once. The plugin resolves the id to
+a **path-like** id (`/node_modules/.weft-css/.css`, with no file there) and loads
+its content: a `\0`-prefixed virtual id breaks Vite's CSS plugin, which imports the module again as
+`…?transform-only` and can't find it. (Both found in the spike.)
+
+**The manifest** (in `virtual:weft/assets`, built from the client build):
+
+- `sheets`: sheet id → URL (in development, the dev server's URL for the virtual module);
+- `entrypoints`: each entrypoint file's chunks, as now, and also its **sheets**: those of every
+ style file in its client module graph (in a build, from the chunks' module ids; in development,
+ from the client environment's module graph, or the scan's own import graph of compiled files);
+- `styles`: the bootstrap's own sheets, as now.
+
+**As built (phase 2).** In `tools/weft_vite_plugin/src/internal/`: `css.ts` (sheet modules,
+stripping, the `Extractor`), `cssPlugin.ts` (the plugin `weft()` includes and `weftCss()` is), and
+the scan's sheet ids (`scan.ts`).
+
+- **Recording is per copy of `weft_css`, not on `globalThis`.** The runner shares the process with a
+ dev server's `rsc` environment, which may render a request (and so run style files' top levels)
+ while an extraction is recording. The runner has its own copy of `weft_css` (`noExternal`), the
+ plugin calls `Extract` through it, and only that runner's files reach it. (Inside `Css`, the
+ recording is a flag and an array: the keyword types' `None` shadows `option`'s.)
+- **The runner is a Vite server of the plugin's own** (no config file, no watcher), whose `weft_css`
+ environment is runnable, in builds as in development. A build extracts every style file at
+ `buildStart` (once, for both environments) and closes it; a dev server extracts on the first
+ request for a sheet and keeps it, and when ReScript rewrites a style file, drops that file from
+ the runner (`evaluatedModules.invalidateModule`) and imports it again, which records only it (its
+ imports have run already). An app with no style files never starts it, so it needn't have
+ `weft_css`.
+- **An extraction error names the file imported, which may not be the one that threw** (a file the
+ style file imports): the message says "importing it (or a file it imports) threw".
+- **Sheets resolve to one path however they're asked for**: `virtual:weft/css/.css`, the path,
+ and the dev server's URL for it (`/node_modules/.weft-css/.css`), so a sheet is one module,
+ and a dev edit invalidates the one the page has.
+- **Stripping** replaces a style call with its name and a global with `void 0`, blanking the rest of
+ the call in place, through Vite's `parseAst`. The CSS plugin runs before `weft()`'s own, and
+ blanks only `'use weft css'`, leaving the others for it.
+- **`weftCss()`'s files import their sheets** (`import "virtual:weft/css/.css"`, on the line the
+ directive was), in client environments only. `weft()`'s import nothing yet: the manifest of
+ sheets, and the pages linking them, are phase 3.
+- **Tests**: `css.test.ts` (resolving, stripping in place), the scan's (sheet ids, a name with
+ another id, two files with one), `weftCss.test.ts` (on the new `styled` fixture: a build, its
+ sheets with the entry's and a lazy chunk's CSS, no definitions or serializer in the JavaScript, a
+ definition that throws, a file needing the browser, a missing PPX; a dev server serving a stripped
+ file and its sheet, and extracting a rewritten file again), and the `basic` build's (no definition
+ in either bundle).
+- **`weft_app`** moved its inline `
diff --git a/examples/weft_app/package.json b/examples/weft_app/package.json
index b8865e0..a937a73 100644
--- a/examples/weft_app/package.json
+++ b/examples/weft_app/package.json
@@ -6,11 +6,13 @@
"type": "module",
"dependencies": {
"@e-cline/weft": "workspace:*",
+ "@e-cline/weft_css": "workspace:*",
"@e-cline/weft_dom": "workspace:*",
"@rescript/runtime": "catalog:"
},
"devDependencies": {
"@e-cline/ppx_weft": "workspace:*",
+ "@e-cline/weft_vite_plugin": "workspace:*",
"jsdom": "catalog:",
"rescript": "catalog:",
"rescript-vitest": "catalog:",
diff --git a/examples/weft_app/rescript.json b/examples/weft_app/rescript.json
index 1b1cc18..d5155d4 100644
--- a/examples/weft_app/rescript.json
+++ b/examples/weft_app/rescript.json
@@ -13,6 +13,6 @@
"suffix": ".res.js",
"namespace": "WeftAppExample",
"dev-dependencies": ["rescript-vitest"],
- "dependencies": ["@e-cline/weft", "@e-cline/weft_dom"],
+ "dependencies": ["@e-cline/weft", "@e-cline/weft_css", "@e-cline/weft_dom"],
"ppx-flags": ["@e-cline/ppx_weft/ppx"]
}
diff --git a/examples/weft_app/src/App.res b/examples/weft_app/src/App.res
index 252f32a..931d795 100644
--- a/examples/weft_app/src/App.res
+++ b/examples/weft_app/src/App.res
@@ -36,6 +36,7 @@ module NewTodo = {