Components hardcode palette shades and raw <a href>, so they cannot follow an app's theme or its router
Package: @stacksjs/components · Measured at: 0.2.175 and 0.2.176 · Scope: all 91 .stx files in src/ui
Two properties of the shipped components make them hard to adopt in an app that themes with tokens or uses the SPA router. Neither is a bug — both render correctly — but together they mean a component library that visibly does not belong to the app that installs it.
Filing these together because they have the same fix shape: the component should defer a decision to the host app instead of baking it in.
1. 62 of 91 components hardcode Tailwind palette shades
Measured by matching \b(?:bg|text|border|ring|divide)-(?:gray|slate|zinc|neutral|stone|red|green|blue|yellow|indigo|purple|pink)-\d{2,3}\b across src/ui:
| count | |
|---|---|
| components with hardcoded palette shades | 62 |
components referencing a CSS variable (var(--…)) | 4 |
components shipping a dark: variant | 67 |
Worst offenders by occurrence count: SubscriptionCheckout (91), Login (52), Signup (48), PasswordInput (40), Navigator (37), TextInput (36).
Dark mode is handled — via Tailwind's dark: variant. What is not handled is a host app with its own palette. An app whose colours live in semantic tokens (bg-panel, border-line, text-subtle, or a theme.extend.colors map) installs <Button> and gets bg-gray-100, which matches nothing around it. There is no prop, no CSS variable and no config hook to redirect it.
Concretely, Breadcrumb.stx renders:
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 …">Home</a>
<span class="text-gray-400 dark:text-gray-600 select-none" aria-hidden="true">/</span>Suggested direction: emit CSS custom properties with the current palette as the fallback, so an unstyled app looks identical and a themed one can override:
class="text-[color:var(--stx-fg-muted,theme(colors.gray.600))]"Even a documented list of the variables each family reads would be enough — the blocker is that there is currently no seam at all.
2. 8 components render a raw <a href>, and 0 use StxLink
All eight, with anchor counts:
| component | family | <a href> |
|---|---|---|
Login | auth | 4 |
Signup | auth | 2 |
TwoFactorChallenge | auth | 1 |
SidebarItem | sidebar | 1 |
SidebarPinned | sidebar | 1 |
Navigator | navigator | 1 |
Breadcrumb | breadcrumb | 1 |
SubscriptionCheckout | payment | 1 |
Components using StxLink: 0.
StxLink is a builtin, registered unconditionally (builtins/index.js), so it is available inside a component with no import and no registration. A raw anchor in an SPA is a full page reload — so adopting <SidebarItem> or <Login> silently opts that part of the app out of client-side navigation.
This is the one that bites hardest, because Sidebar*, Breadcrumb, Navigator and the auth family are exactly the navigation surfaces an app adopts first — and they are precisely where a full page reload is most visible.
Suggested direction: use StxLink for internal targets, falling back to <a> for absolute/external URLs — or take an as prop so the host can supply its own link component.
3. Minor: RadioGroup renders a hydration shell
<RadioGroup :options="[{label:'…',value:'a'}]" /> emits a data-stx-scope wrapper with the labels only inside data-stx-props. Nothing readable is in the server HTML, so the options are invisible without JS and to a crawler.
Measured by stripping every attribute value from the output before searching for the label. Nine other components checked the same way — including Tabs, Accordion, Select, Navigator, Breadcrumb and VirtualTable — do server-render their content, so this looks like an oversight in one component rather than a design choice.
Method
Each figure comes from rendering through processDirectives(template, context, filePath, options, dependencies) with the plugin dir registered, then stripping the injected crosswind <style> block before asserting on the output.
Two notes for anyone reproducing it: processDirectives takes five arguments and omitting dependencies makes every call throw — a probe that only checks for known failure strings will then report clean passes over non-renders. And the injected <style> block is not evidence that a component rendered; assert on markup only that component can emit.
Unrelated good news
The two defects that had 32 of these components quarantined in our internal standards are both fixed as of 0.2.175 — processDynamicComponents now runs before processComponents, and parseLoopBinding accepts the @foreach(x in y) header form. All 36 previously-forbidden tags render cleanly. Thank you.