ReviewOS

stacks/stx

Crosswind: a shortcut silently overrides any utility that contradicts it, because shortcut rules are emitted twice

#2000
Closed glennmichael123 opened this 9 days ago ·
9 days ago

What happens

class="text-xs btn-primary" renders at text-sm. So does class="btn-primary text-xs". The utility never applies, in either order.

The same is true of colours: with

shortcuts: {
  'btn-primary': 'inline-flex px-4 py-2 font-semibold text-sm text-white bg-blue-600 … rounded-xl',
}

class="text-white bg-rose-600 hover:bg-rose-700 btn-primary" renders blue.

Why

The shortcut is emitted twice in the generated stylesheet. In one app's _stx/css.*.css:

.btn-primary { … }      at byte 19498
.text-xs     { … }      at byte 33120
.btn-primary { … }      at byte 42982   ← again, after every utility

Both are single-class selectors, so specificity ties and source order decides. The second copy of the shortcut is last, so the shortcut always wins.

The duplicate looks like two different emitters: the first copy is pretty-printed, the second is on one line.

.btn-danger-ghost {\n  display: inline-flex;\n  align-items: center;\n  …
.btn-danger-ghost { display: inline-flex; align-items: center; …

Why this is worth fixing rather than documenting

It fails silently and it fails plausibly. There is no build error, no console warning, and the element still renders as a button — just not the one the source describes. Nothing about reading the template tells you the utility is dead.

In the app that found this, it had produced:

  • 13 destructive buttons drawn as safe ones. Every "Delete this workout", "Delete plan", "Confirm delete", "Remove", "Disconnect", "Stop sharing" and "Cancel membership" was written with rose over a btn-primary/btn-ghost shortcut and rendered in the primary blue or plain slate — a delete confirmation styled identically to Save, sitting right next to it.
  • 36 buttons meant to be small that are not. text-xs next to a shortcut, across 14 files, none of it applying. Confirmed against text-sm and text-[11px] too; only !text-xs wins.

Nobody noticed either, for as long as the code has existed, which is the point: the failure mode is a button that looks deliberate.

Expectation

A utility written alongside a shortcut should win, the way @apply-style shortcuts behave in Tailwind and UnoCSS — the shortcut is the base, the utility is the override. That is what every call site here was written assuming.

At minimum, the shortcut should be emitted once. If the duplicate is intentional (safelist re-emission?), the second copy could be dropped when the first is already present.

Reproducing

<button class="text-xs btn-primary">small?</button>
<button class="btn-primary text-xs">small?</button>
<button class="!text-xs btn-primary">small</button>

getComputedStyle(...).fontSize → 14px, 14px, 12px.

Measured on @stacksjs/stx 0.2.339 / crosswind as shipped with stacks 0.75.34, in a browser against the served stylesheet.

9 days ago

Two things I learned trying to work around this, both of which make it worse than the original report.

The second-pass order does not follow the config

I assumed the duplicate emission at least preserved the shortcuts object's order, so that a modifier declared after a variant would win. It does not.

Adding 'btn-sm': 'px-3 py-1.5 text-xs' last in the shortcuts object, and applying it as class="btn-primary btn-sm", produced this on six different pages of the same app:

pagelast .btn-smlast .btn-primarymodifier wins
/coaches/store6046560615no
/coaches/plans6076060910no
/profile5700357153no
/workouts5497955129no
/calendar5894759097no
/purchases5571855868no

The modifier lands ~150 bytes before the variant every time, i.e. the reverse of the declaration order, consistently. Moving btn-sm to the top of the object changed nothing about the relative order — it stayed wrong.

So there is no config-level ordering that makes one shortcut modify another. A shortcut composed of other shortcuts is not expressible.

Only !important works, which means it is the documented answer

The version that ships is:

'btn-sm': '!px-3 !py-1.5 !text-xs',

That works on every page, in both themes, against all five variants. But it means any app wanting a size or state modifier alongside a variant shortcut has to reach for !important in its config, and then anything that later needs to override the modifier needs !important too. That is a cascade the app has to keep in its head forever, for a composition that reads as ordinary.

Worth noting: with !, the shortcut is emitted once rather than twice. Whatever produces the duplicate does not seem to apply to important-flagged declarations — that may be a useful pointer to where the duplicate comes from.

Why I think this is the fix, not a doc note

The original report was 13 wrong-coloured buttons and 36 wrong-sized ones in one app. The workaround for the second half is !important in the config, which is the kind of thing that gets copied into every downstream app's config and never removed.

If shortcuts were emitted once, in declaration order, both halves would resolve the way every user already assumes: utilities override shortcuts, and a later shortcut overrides an earlier one.

9 days ago

Fixed in ac701fc56e. Your diagnosis was right down to the detail — including "two different emitters: the first copy is pretty-printed, the second is on one line." That is exactly what it was, and the second emitter was stx, not Crosswind.

The engine has handled shortcuts correctly all along, variants included. generateCssUncached was re-deriving .name { … } by scraping declarations out of the already-generated stylesheet and appending the result — so every used shortcut appeared twice, and the appended copy landed after every utility. Both single-class selectors, specificity ties, source order decides, last one wins. Hence both orders rendering at text-sm.

Verified against the real pipeline, both orders:

class="text-xs btn-primary"      .btn-primary copies: 1
                                 .text-xs     AFTER the shortcut -> wins
                                 .bg-rose-600 AFTER -> rose wins
class="btn-primary text-xs"      identical

Shortcuts now go through their own generator and are concatenated first, so it is order-independent rather than order-independent in the wrong direction.

Deleting the scraping took two bugs with it that nobody had filed

It only knew how to handle dark: among variants, so hover: and focus: inside a shortcut were dropped from the grouped rule entirely. And its dark output was:

@media (prefers-color-scheme: dark) { .dark .name { … } }

which needs the media query and the class to match, while the rest of stx keys dark mode on the class alone. That rule could never apply in an app pinned to dark on a light OS — which is precisely the case stx's own color-mode boot exists to support. The engine resolves all of it properly: .btn-primary:hover, .dark .btn-primary, .btn-primary:focus.

It also stops generating each shortcut's constituent utilities into the main sheet. The engine inlines those declarations into the grouped rule, so .px-4 and friends were dead weight unless something else used them — smaller stylesheets as a side effect.

On your framing

It fails silently and it fails plausibly. … the failure mode is a button that looks deliberate.

That is the part that made this worth doing ahead of other work, and the 13-destructive-buttons-drawn-as-safe measurement is the sharpest version of it I have seen in a report. A delete confirmation styled identically to the Save next to it is not a styling bug.

The test asserts byte order rather than presence, because presence was never the problem: both rules were in the sheet and the wrong one was last. Its first version counted wrong, incidentally — a plain indexOf for .btn-primary { also matches inside .dark .btn-primary {, so the shortcut's own dark variant read as a duplicate and the file reported the bug unfixed when it was already fixed. Anchored at a rule start now.

Full suite green: 14,151 pass, 0 fail.

No !important needed anywhere — !text-xs should no longer be necessary in any of those 14 files.

Sign in to comment on this issue.