Summary
Crosswind only scans markup for class names and emits utilities. There is no entry stylesheet, no @apply, no theme() function and no nesting, so every line of CSS an author has to write by hand — anything utilities can't express, and anything shared as a named class — lives in a file the engine never sees. CrosswindConfig is scan-shaped (content globs in, output path out) and build() is scan-then-emit. In a real app the result is a second, entirely unprocessed design system living next to the generated one, with no way for the two to reference each other.
Impact (real app)
analyticshq is 35 static pages on stx + crosswind. It ships 1,059 lines of CSS the build never sees.
public/marketing.css is 905 lines / 235 class rules, hand-written, parked in public/ and pulled in with a raw <link rel="stylesheet" href="/marketing.css"> at resources/layouts/marketing.stx:22. Its own header comment states the intent:
/* analyticshq marketing surface — shared design tokens + components.
Extracted from the landing so every marketing page (landing, feature
pages, use-cases) stays visually identical. Dark-locked, Geist, mint accent. */
That file is the entire marketing design system — layout shell, nav + mega-menu, bento grid, buttons, chips, comparison tables, scroll-reveal — including its own desktop-first breakpoints at marketing.css:691/697/702/710/772/777/806/853/901. It exists because there is nowhere else to put it.
Another 154 lines sit in four in-template <style> blocks: resources/layouts/auth.stx:25-48, resources/views/dashboard.stx:1069-1136, resources/views/account.stx:88-118, resources/views/pricing.stx:59-89. Every rule in them is @apply-shaped. From auth.stx:35-44:
.field { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; color: var(--text); }
.field:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); }
.btn { background: var(--accent); color: var(--accent-ink); border-radius: 10px; transition: transform 0.12s ease, opacity 0.15s ease; }
.oauth-btn { border: 1px solid var(--border); border-radius: 10px; color: var(--text); background: var(--panel); … }
Because that block cannot read the crosswind theme, it re-types the tokens as raw custom properties at auth.stx:26-32 — and those values have already drifted from the ones config/crosswind.ts:29-38 registers.
The knock-on: the 27 marketing pages use zero crosswind utilities, and instead carry one-off inline overrides (style="margin-top:1.1rem", style="font-family:var(--sans)" repeated across resources/views/use-cases/*.stx:104-135). None of the 1,059 lines gets minification, dead-rule elimination, theme typing, or any relationship to the generated stylesheet.
Evidence in crosswind
Against origin/main 43ac41b (v0.2.16, the version installed in the app):
packages/crosswind/src/types.ts:88-121 — CrosswindConfig keys are content, output, minify, watch, verbose, includePreflight, darkMode, theme, fonts, shortcuts, rules, variants, safelist, blocklist, preflights, presets, compileClass, attributify, bracketSyntax, codeStrings, cssVariables. There is no input/entry stylesheet key of any kind.
packages/crosswind/src/build.ts:19-67 — build() is scan-then-generate end to end: new Scanner(config.content, transformer, …).scan() → generator.generate(className) per class → generator.toCSS(...). No CSS is ever taken as input.
rg -n '@apply|at-apply|--at-apply|transformDirectives|theme\(' packages/crosswind/src → 0 hits across all 23 source files. Control: rg -n 'shortcuts' packages/crosswind/src returns many, so the search is sound. Same for postcss|nesting|@utility|@layer components|@screen.
packages/crosswind/src/index.ts re-exports only build config generator parser plugin preflight preflight-forms rules scanner types — there is no transformer or directive-processing entry point to reach for.
- The nearest escape hatch is
preflights: Preflight[] (types.ts:109), and Preflight is { getCSS: () => string } (types.ts:239-241) — an opaque string that is concatenated, never parsed, so @apply inside it is not expanded. CustomRule (types.ts:237) is [RegExp, (m) => Record<string, string> | undefined] — declarations only, so a plugin rule cannot emit a selector or an at-rule either.
So there is no path, at any layer, from a hand-written .btn { … } into the engine.
Prior art
- Tailwind v3 — an entry
app.css with @tailwind base/components/utilities, @apply inside @layer components, and the theme('colors.accent') function so hand-written CSS reads the same tokens.
- Tailwind v4 —
@import "tailwindcss", @theme { … }, @utility, @apply, native nesting; the stylesheet is the config.
- UnoCSS —
@unocss/transformer-directives gives @apply / --at-apply / theme() in ordinary CSS files.
- Vue SFC —
<style lang="postcss"> runs through the same pipeline as everything else, so a component's CSS is a first-class build input.
Ask
- Accept an input stylesheet — an
input (or entry) key on CrosswindConfig, plus the CLI and bun-plugin equivalents — that crosswind parses and emits, rather than only scanning markup for class names.
- Support
@apply <utilities>; inside that stylesheet, expanding to the same declarations the utilities produce, variants included. Shortcuts already prove the merge logic exists (generator.ts:1766-1784 emits a shortcut's utilities under the shortcut's own selector) — @apply is that machinery driven from CSS instead of from config.
- Support a
theme('colors.accent') / theme('spacing.4') function inside that stylesheet, so hand-written CSS reads the registered theme instead of retyping var(--x) names the engine does not know about.
- Support CSS nesting in the input, so component blocks don't have to be flattened by hand.
- Until the above lands, make the limit discoverable rather than silent:
@apply written in a preflights entry or in a host-framework <style> block currently ships verbatim to the browser as an unknown at-rule, with no warning from the build.
Related: #20, #21, #23
Summary
Crosswind only scans markup for class names and emits utilities. There is no entry stylesheet, no
@apply, notheme()function and no nesting, so every line of CSS an author has to write by hand — anything utilities can't express, and anything shared as a named class — lives in a file the engine never sees.CrosswindConfigis scan-shaped (contentglobs in,outputpath out) andbuild()is scan-then-emit. In a real app the result is a second, entirely unprocessed design system living next to the generated one, with no way for the two to reference each other.Impact (real app)
analyticshq is 35 static pages on stx + crosswind. It ships 1,059 lines of CSS the build never sees.
public/marketing.cssis 905 lines / 235 class rules, hand-written, parked inpublic/and pulled in with a raw<link rel="stylesheet" href="/marketing.css">atresources/layouts/marketing.stx:22. Its own header comment states the intent:That file is the entire marketing design system — layout shell, nav + mega-menu, bento grid, buttons, chips, comparison tables, scroll-reveal — including its own desktop-first breakpoints at
marketing.css:691/697/702/710/772/777/806/853/901. It exists because there is nowhere else to put it.Another 154 lines sit in four in-template
<style>blocks:resources/layouts/auth.stx:25-48,resources/views/dashboard.stx:1069-1136,resources/views/account.stx:88-118,resources/views/pricing.stx:59-89. Every rule in them is@apply-shaped. Fromauth.stx:35-44:Because that block cannot read the crosswind theme, it re-types the tokens as raw custom properties at
auth.stx:26-32— and those values have already drifted from the onesconfig/crosswind.ts:29-38registers.The knock-on: the 27 marketing pages use zero crosswind utilities, and instead carry one-off inline overrides (
style="margin-top:1.1rem",style="font-family:var(--sans)"repeated acrossresources/views/use-cases/*.stx:104-135). None of the 1,059 lines gets minification, dead-rule elimination, theme typing, or any relationship to the generated stylesheet.Evidence in crosswind
Against
origin/main43ac41b (v0.2.16, the version installed in the app):packages/crosswind/src/types.ts:88-121—CrosswindConfigkeys arecontent,output,minify,watch,verbose,includePreflight,darkMode,theme,fonts,shortcuts,rules,variants,safelist,blocklist,preflights,presets,compileClass,attributify,bracketSyntax,codeStrings,cssVariables. There is no input/entry stylesheet key of any kind.packages/crosswind/src/build.ts:19-67—build()is scan-then-generate end to end:new Scanner(config.content, transformer, …).scan()→generator.generate(className)per class →generator.toCSS(...). No CSS is ever taken as input.rg -n '@apply|at-apply|--at-apply|transformDirectives|theme\(' packages/crosswind/src→ 0 hits across all 23 source files. Control:rg -n 'shortcuts' packages/crosswind/srcreturns many, so the search is sound. Same forpostcss|nesting|@utility|@layer components|@screen.packages/crosswind/src/index.tsre-exports onlybuild config generator parser plugin preflight preflight-forms rules scanner types— there is no transformer or directive-processing entry point to reach for.preflights: Preflight[](types.ts:109), andPreflightis{ getCSS: () => string }(types.ts:239-241) — an opaque string that is concatenated, never parsed, so@applyinside it is not expanded.CustomRule(types.ts:237) is[RegExp, (m) => Record<string, string> | undefined]— declarations only, so a plugin rule cannot emit a selector or an at-rule either.So there is no path, at any layer, from a hand-written
.btn { … }into the engine.Prior art
app.csswith@tailwind base/components/utilities,@applyinside@layer components, and thetheme('colors.accent')function so hand-written CSS reads the same tokens.@import "tailwindcss",@theme { … },@utility,@apply, native nesting; the stylesheet is the config.@unocss/transformer-directivesgives@apply/--at-apply/theme()in ordinary CSS files.<style lang="postcss">runs through the same pipeline as everything else, so a component's CSS is a first-class build input.Ask
input(orentry) key onCrosswindConfig, plus the CLI and bun-plugin equivalents — that crosswind parses and emits, rather than only scanning markup for class names.@apply <utilities>;inside that stylesheet, expanding to the same declarations the utilities produce, variants included. Shortcuts already prove the merge logic exists (generator.ts:1766-1784emits a shortcut's utilities under the shortcut's own selector) —@applyis that machinery driven from CSS instead of from config.theme('colors.accent')/theme('spacing.4')function inside that stylesheet, so hand-written CSS reads the registered theme instead of retypingvar(--x)names the engine does not know about.@applywritten in apreflightsentry or in a host-framework<style>block currently ships verbatim to the browser as an unknown at-rule, with no warning from the build.Related: #20, #21, #23