Evidence from loghq, a production SSR app on stx + crosswind (33 routes, 6.5k lines of templates). It ships three hand-written stylesheets next to the generated one, and they exist because the engine could not express what is in them.
Measured in the app
|
count |
Hand-written rules in public/app-chrome.css |
46 |
| — pseudo-element rules |
7 |
— attribute-state selectors ([data-on], [data-sel], [data-t]) |
9 |
— @keyframes |
5 |
Custom properties in public/tokens.css |
42 |
| Distinct font sizes across the stylesheets |
~30 |
config/crosswind.ts registers theme.extend with colors, screens and fontFamily only. Everything dimensional — type, spacing, radius, shadow — is hand-typed per element as an arbitrary value, so there is no scale to be consistent with. The result is 30 font sizes in an app with maybe eight real roles.
The gaps, each behind a concrete workaround
-
No type / spacing / radius / shadow scale. theme.extend.fontSize, .spacing, .borderRadius, .boxShadow are not usable, so every size is text-[13px] / rounded-[10px] / shadow-[0_24px_60px_rgba(0,0,0,0.22)]. The app has exactly three shadows, all hand-written, none theme-aware.
-
No custom @keyframes / animation tokens. Five @keyframes (dz-fade, dz-pop, loghq-pulse, …) live in a hand-written sheet because there is no theme.keyframes / theme.animation. Animations are then applied with arbitrary properties: [animation:loghq-pulse_1.4s_ease-in-out_infinite].
-
No plugin API that can emit a selector. The public rules entry cannot produce a pseudo-element or an attribute-state selector, so .tgl::after, .tgl[data-on='1'], .seg[data-sel='1'], .theme-btn icon swapping — all state-driven UI — are stranded in hand-written CSS.
-
Dynamic class names are silently dropped. class="badge-{{ c.type }}" emits badge-slack / badge-discord and generates nothing. There is no safelist pattern (regex/glob) to cover a class assembled at render time. This shipped as an unstyled badge in production and was invisible to review, because the class name does not exist anywhere to grep for.
-
Arbitrary property + variant does not combine. dark:[background-image:…] for a themed select chevron had to be written by hand.
-
Arbitrary/custom max-width variants (max-[900px]:, max-bp-md:) are unavailable, so the marketing sheet keeps its own media queries — and the app surface ended up with zero width media queries at all.
Why it compounds
Because the scale is not registered, nothing enforces it — the string hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] appears 20 times verbatim across the views. A registered theme plus a working plugin API would delete most of app-chrome.css and let the design system be checked rather than retyped.
Related: #19 (responsive variants dropped on SPA fragment swap) is the same class of problem seen from the delivery side.
Evidence from loghq, a production SSR app on stx + crosswind (33 routes, 6.5k lines of templates). It ships three hand-written stylesheets next to the generated one, and they exist because the engine could not express what is in them.
Measured in the app
public/app-chrome.css[data-on],[data-sel],[data-t])@keyframespublic/tokens.cssconfig/crosswind.tsregisterstheme.extendwith colors, screens and fontFamily only. Everything dimensional — type, spacing, radius, shadow — is hand-typed per element as an arbitrary value, so there is no scale to be consistent with. The result is 30 font sizes in an app with maybe eight real roles.The gaps, each behind a concrete workaround
No type / spacing / radius / shadow scale.
theme.extend.fontSize,.spacing,.borderRadius,.boxShadoware not usable, so every size istext-[13px]/rounded-[10px]/shadow-[0_24px_60px_rgba(0,0,0,0.22)]. The app has exactly three shadows, all hand-written, none theme-aware.No custom
@keyframes/ animation tokens. Five@keyframes(dz-fade,dz-pop,loghq-pulse, …) live in a hand-written sheet because there is notheme.keyframes/theme.animation. Animations are then applied with arbitrary properties:[animation:loghq-pulse_1.4s_ease-in-out_infinite].No plugin API that can emit a selector. The public
rulesentry cannot produce a pseudo-element or an attribute-state selector, so.tgl::after,.tgl[data-on='1'],.seg[data-sel='1'],.theme-btnicon swapping — all state-driven UI — are stranded in hand-written CSS.Dynamic class names are silently dropped.
class="badge-{{ c.type }}"emitsbadge-slack/badge-discordand generates nothing. There is no safelist pattern (regex/glob) to cover a class assembled at render time. This shipped as an unstyled badge in production and was invisible to review, because the class name does not exist anywhere to grep for.Arbitrary property + variant does not combine.
dark:[background-image:…]for a themedselectchevron had to be written by hand.Arbitrary/custom max-width variants (
max-[900px]:,max-bp-md:) are unavailable, so the marketing sheet keeps its own media queries — and the app surface ended up with zero width media queries at all.Why it compounds
Because the scale is not registered, nothing enforces it — the string
hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]appears 20 times verbatim across the views. A registered theme plus a working plugin API would delete most ofapp-chrome.cssand let the design system be checked rather than retyped.Related: #19 (responsive variants dropped on SPA fragment swap) is the same class of problem seen from the delivery side.