Skip to content

Ship a scoped shadcn-compat token layer so generic CSS variables (--card, --border, --primary, …) resolve to the active brand #309

Description

@horner

Problem

Developers (and AI code generators — v0, Copilot, shadcn copy-paste) reflexively use the generic shadcn-style CSS variables (--card, --border, --muted-foreground, --primary, --destructive, …) instead of the --mieweb-* design tokens. This keeps happening despite instructions, grep audits, and code review — the generic names are the ecosystem default, so the reflex will never go away.

The failure mode is nasty because it's silent and partial: shadcn-style code ships light fallbacks (var(--card, #fff)). No brand CSS defines the generic names, so the fallback renders — but only visibly breaks in dark mode (e.g. a white card on a dark chart), only for the missed tokens. This has shipped repeatedly in echart-sim.

Today the situation is the worst of both worlds: generic names silently half-work instead of either working or failing loudly.

Proposal: scoped shadcn-compat alias layer

Ship a compatibility layer in @mieweb/ui (styles.css) that maps the ~15 shadcn semantic tokens to the mieweb tokens, scoped to the app container, not :root:

// shadcn-compat: generic ecosystem names resolve to the active brand.
// Scoped to the app root so aliases can't leak into a host page,
// and a host page's :root definitions can't leak in.
.mieweb-app {
  --background: var(--mieweb-background);
  --foreground: var(--mieweb-foreground);
  --card: var(--mieweb-card);
  --card-foreground: var(--mieweb-card-foreground);
  --muted: var(--mieweb-muted);
  --muted-foreground: var(--mieweb-muted-foreground);
  --border: var(--mieweb-border);
  --input: var(--mieweb-input);
  --primary: var(--mieweb-primary-500);
  --primary-foreground: var(--mieweb-primary-foreground);
  --secondary: var(--mieweb-secondary);
  --accent: var(--mieweb-accent);
  --destructive: var(--mieweb-destructive);
  --ring: var(--mieweb-ring);
  --radius: var(--mieweb-radius);
  // ...complete the shadcn semantic set
}

Why this design works

  • Inheritance does the fan-out. CSS custom properties cascade, so one declaration on the wrapper covers every descendant var(--card) — no per-tag prefixing.
  • Dark mode is free. Aliases point at --mieweb-*, which are re-declared under [data-theme='dark'] on <html>; var() re-resolves at each element, so the alias picks up the dark value automatically. No dark variants needed in the compat layer.
  • Brand switching is free for the same reason — swap the brand <link> and everything re-resolves.
  • Collision protection both ways. Scoping to .mieweb-app (instead of :root) means a host page's :root { --primary: … } loses to the closer ancestor, and our aliases never escape the app subtree. Important because these apps are embedded in host pages (WebChart) that may load their own Tailwind/shadcn themes.
  • Fallbacks become harmless. var(--card, #fff)'s fallback only renders if the compat layer is missing entirely — an obvious whole-page failure instead of a subtle per-token, dark-mode-only one.

Invariants / follow-ups

  1. Completeness is the new invariant. The failure mode shifts from "developer used a banned name" to "compat layer is missing a token." Add a small CI check: extract every var(--x) used in library/app SCSS and assert each generic --x appears in the compat file (replaces the current grep -v mieweb audit).
  2. The wrapper must wrap everything, including portals/modals rendered into document.body. Either apply the class at app boot on <html>/<body> (fine standalone, riskier embedded) or ensure portal containers also carry .mieweb-app.
  3. First-party library SCSS should still use --mieweb-* directly — the compat layer is a safety net for imported/generated/consumer code, not a second first-class vocabulary.

Context

  • Convention docs that keep getting violated: echart-sim .github/copilot-instructions.md ("Never use generic shadcn-style variables…") and repeated dark-mode regressions in component PRs.
  • Analogy: generic names in the leaves, one branded adapter at the root — same shape as React context vs. prop-drilling.

Metadata

Metadata

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