Skip to content

feat: an input stylesheet crosswind actually processes — @apply / theme() / nesting #24

Description

@glennmichael123

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-121CrosswindConfig 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-67build() 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/src0 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

  1. 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.
  2. 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.
  3. 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.
  4. Support CSS nesting in the input, so component blocks don't have to be flattened by hand.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions