Summary
injectCrosswindCSS already has two output modes: a content-hashed <link> to /_stx/crosswind.<hash>.css, and an inline <style data-crosswind="generated">. Only the dev/serve path passes the flag that selects the first. Every static and production render path calls it with the default, so each page carries its own full copy of the generated CSS — preflight included — and no two pages share a cache entry. There is also no per-layout preflight control, so a page that supplies its own reset gets a second one it cannot turn off.
Impact (real app)
analyticshq builds to 35 HTML pages and exactly one .css file, and that one is the app's own hand-written marketing.css — nothing crosswind generated is ever written to disk. Measured on the built output:
| page | inline <style data-crosswind> |
|---|---|
dist/index.html | 2,863 bytes |
dist/features.html | 2,813 bytes |
dist/pricing.html | 4,623 bytes |
dist/dashboard.html | 4,641 bytes |
grep -c 'data-crosswind="generated" rel="stylesheet"' dist/index.html → 0. Every page took the inline branch.
On the 27 marketing pages that block is almost entirely preflight, because those pages use no crosswind utilities at all — their styling comes from the hand-written sheet. So the reset is re-downloaded on every navigation and never cached across pages.
And because preflight is project-wide or nothing, the app ends up with two resets. config/crosswind.ts:21 sets preflight: true globally, and then public/marketing.css:25-55 hand-writes its own on top:
* { box-sizing: border-box; }
html { background: var(--void); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body { margin: 0; … }
a { color: inherit; text-decoration: none; }That second reset exists only because the first one is not controllable per layout.
Evidence in stx
packages/stx/src/dev-server/crosswind.ts (v0.2.155, origin/main 1c053f2):
781 export async function injectCrosswindCSS(htmlContent: string, appDir?: string, serveMode = false)
…
800 const assetTag = serveMode
801 ? `<link data-crosswind="generated" rel="stylesheet" href="/_stx/crosswind.${registerServeCss(css)}.css">`
802 : `<style data-crosswind="generated">\n${css}\n</style>`The hashed-asset path is already built and already public: registerServeCss at :95-103 (xxHash64 via Bun.hash, LRU-bounded), getCrosswindServeAsset at :105-113, exported from packages/stx/src/index.ts:176 and served by packages/bun-plugin/dist/serve.js:10055. Nothing new needs inventing — it just isn't reachable from a build.
Every static/production caller takes the inline branch:
packages/stx/src/ssg.ts:687—html = await injectCrosswindCSS(html)packages/stx/src/production-builder.ts:275—compiled.html = await injectCrosswindCSS(compiled.html)packages/stx/src/site-builder/build.ts:144—html = await injectCrosswindCSS(html, process.cwd())packages/stx/src/render.ts:409—output = await injectCrosswindCSS(output)
Only packages/stx/src/process.ts:650 passes a value: injectCrosswindCSS(result, undefined, options.buildMode === 'serve').
Preflight is one boolean for the whole project — packages/stx/src/dev-server/crosswind.ts:687-688:
const includePreflight = (userConfig as any).preflight !== falseapplied identically to every page, and crosswind's own surface is likewise a single flag (includePreflight, crosswind packages/crosswind/src/types.ts:95, consumed in generator.ts:2793-2818). Neither package exposes a per-layout or per-route hook.
Prior art
- Nuxt / Vite emit one content-hashed CSS chunk per entry, shared across navigations and served with long-lived cache headers.
- Next.js does per-route CSS chunking on top of a shared base chunk, so common CSS is fetched once.
- Astro emits shared stylesheets and exposes
build.inlineStylesheets: 'always' | 'auto' | 'never'so inlining is a decision, not a default. - Tailwind
corePlugins: { preflight: false }(v3) and importingtailwindcss/utilitiesalone (v4) let a project or a specific entry opt out of the reset.
Ask
- Write the generated CSS to a content-hashed file in the build output (
dist/_stx/crosswind.<hash>.css) and reference it with a<link>, on the SSG (ssg.ts:687), production-builder (production-builder.ts:275) and site-builder (site-builder/build.ts:144) paths — the same shapeserveModealready produces atdev-server/crosswind.ts:801. - Emit one shared asset for the union of pages where the class sets overlap, rather than one file per page, so cross-page navigation is a cache hit rather than a fresh parse.
- Keep an explicit escape hatch (
inlineStylesheets: 'auto' | 'always' | 'never', or acrosswindAssetmode on the build options) so small pages can still inline — flipping the default with no opt-out would be its own regression. - Allow preflight to be set per layout or per route (e.g. a layout-level
preflight: false), not only project-wide, so the pages that ship their own reset stop getting two.
Related: #1822 (which made the project-wide preflight flag actually take effect)