Nychthemeron/docs/superpowers/specs/2026-07-10-primevue-to-shadcn-vue-migration-design.md
Matthew L McPeak 302a58c258
Some checks failed
ci / build (push) Failing after 21s
ci / publish (push) Has been skipped
ci / publish-docs (push) Has been skipped
Inital Commit
2026-07-15 20:01:01 -04:00

10 KiB

PrimeVue → shadcn-vue Migration Design

Date: 2026-07-10 Project: nychthemeron Status: Approved

Overview

Replace PrimeVue as the underlying component primitive layer with shadcn-vue (Reka UI primitives + Tailwind CSS v4 + class-variance-authority), while preserving nychthemeron's identity: the Hades/Apollo custom-property theme system, the @nychthemeron/library installable-package distribution model, and the createNychthemeron() global-registration plugin.

Goals

  • Remove primevue as a dependency; components are built on Reka UI primitives instead.
  • Adopt Tailwind CSS v4 and cva-based variants as the styling mechanism, replacing hand-written per-component CSS.
  • Preserve the Hades/Apollo design tokens (CSS custom properties) as the source of truth for color/typography, mapped onto Tailwind's expected theme slots.
  • Replace the generic ThemeEngine/Themeable/pt-injection wrapping layer with the much smaller cn() utility (clsx + tailwind-merge) that shadcn-vue itself generates, plus shared cva variant configs where genuinely duplicated.
  • Adopt shadcn-vue's idiomatic compound-component API for multi-part components (Dialog, Select, Card), even though this breaks the current single-component prop/slot API.
  • Keep the library an installable workspace package (no shadcn-vue CLI/registry distribution model for consumers) — the CLI is used once, at authoring time, to scaffold component source into packages/library.

Non-goals

  • No change to the monorepo structure (packages/library, packages/playground), the bun workspace setup, or the "no npm publishing" distribution model.
  • No redesign of the Hades/Apollo color palette or typography — token values stay the same; only how they're wired into the styling layer changes.
  • No visual-regression parity requirement with the old PrimeVue-based rendering — some spacing/detail differences are expected and acceptable since components are being rebuilt from shadcn-vue's scaffolds.

Architecture

Dependencies

  • Removed: primevue (peer dependency of packages/library; dependency of packages/playground).
  • Added (library peer deps): vue, reka-ui.
  • Added (library deps/devDeps): tailwindcss, @tailwindcss/vite, class-variance-authority, clsx, tailwind-merge, plus whatever icon package shadcn-vue's scaffolds default to (e.g. lucide-vue-next) if generated components reference it.
  • shadcn-vue itself is never a runtime dependency — it is a one-time code generator (npx shadcn-vue@latest init / add <component>) invoked during implementation to scaffold component source directly into packages/library/src/components. Generated source is then adapted (token wiring, extra variants, our custom extensions) and owned in-repo like any other component.

Styling & theming

  • Tailwind v4, CSS-first configuration (no tailwind.config.ts); Tailwind is imported via @import "tailwindcss" in the library's CSS entry point, using the Vite plugin (@tailwindcss/vite) in both packages/library/vite.config.ts and packages/playground/vite.config.ts / Storybook Vite config.
  • A @theme block maps existing Hades/Apollo custom properties onto the canonical color slots shadcn-vue's generated cva variants expect:
    • --color-primary: var(--primary), --color-primary-foreground: var(--primary-fg)
    • --color-secondary: var(--neutral), --color-secondary-foreground: var(--neutral-fg)
    • --color-destructive: var(--danger), --color-destructive-foreground: var(--danger-fg)
    • --color-background: var(--surface-0), --color-foreground: var(--text-body)
    • --color-card: var(--surface-1), --color-card-foreground: var(--text-body)
    • --color-popover: var(--surface-2), --color-popover-foreground: var(--text-body)
    • --color-border: var(--border), --color-input: var(--border), --color-ring: var(--primary)
    • Additional semantic slots for nychthemeron's 5-way severity system beyond shadcn's stock set: --color-info, --color-info-foreground, --color-success, --color-success-foreground, --color-warning, --color-warning-foreground (all mapped from existing --info/--success/--warn tokens and their -fg counterparts).
  • Theme switching mechanism is unchanged: [data-theme="hades"] / [data-theme="apollo"] selectors on <html> continue to define the underlying custom properties; Tailwind utilities simply read through var() at paint time, so the Storybook theme toolbar keeps working without modification.
  • Global concerns (font-family tokens, base box-sizing reset, :focus-visible ring, :disabled opacity) remain in one small global CSS file, imported once. All per-component layout/spacing/color styling that used to live in nychthemeron.css/theme.css moves into Tailwind utility classes and cva variants inside each component's own .vue file — the shadcn-vue idiom.
  • packages/library exports (./style, ./theme) keep the same public paths; the files they point at change from hand-written CSS to the new Tailwind-entry CSS.

Engine replacement

  • packages/library/src/engine/ (ThemeEngine, Themeable, LibComponent, Key, Props, pt-injection logic) is deleted in full.
  • Its replacement is the CLI-generated src/lib/utils.ts exporting cn() (clsx + tailwind-merge), used inside every component for merging caller-provided class props with internal variant classes.
  • Where a color/severity mapping is genuinely shared across components (e.g. the 5-way severity set used by both Badge and Alert), factor it into one shared cva base (e.g. src/lib/variants.ts) rather than duplicating the variant list — but this is a targeted, small shared config, not a generic runtime wrapper.
  • No generic "wrap any component and inject classes via props" mechanism is preserved; shadcn-vue's model (own the markup, write the classes directly) makes that unnecessary.

Component mapping

Current (PrimeVue-wrapped) shadcn-vue CLI component(s) Notes
Button button Single component; cva variant prop replaces severity (primary/secondary/info/success/warning/danger); loading→disabled behavior and the loading-spinner rendering are coded directly in the SFC.
LoadingIcon (none — custom SVG) Unchanged, re-exported as-is.
InputTextInput input Direct port.
Textarea textarea Direct port.
Checkbox + CheckboxGroup checkbox shadcn-vue has no stock CheckboxGroup; hand-build one using the same provide/inject pattern the CLI's own radio-group uses, so it matches the surrounding idiom.
RadioButton + RadioButtonGroup radio-groupRadioGroup, RadioGroupItem Direct CLI equivalent; naming changes to match.
ToggleSwitchSwitch switch Direct port, renamed.
Select selectSelect, SelectTrigger, SelectValue, SelectContent, SelectItem Compound API replaces the options/optionLabel/optionValue props; consumers write <SelectItem> children.
Card cardCard, CardHeader, CardTitle, CardDescription, CardContent, CardFooter #subtitle slot → CardDescription; header-image use case becomes a plain child placed before CardHeader.
Dialog dialogDialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose Current headerActions (multiple header buttons, not just close) becomes extra children placed in DialogHeader alongside DialogClose.
MessageAlert alertAlert, AlertTitle, AlertDescription Stock Alert has no close button; add a closable prop / optional close-button piece as our extension on top of the scaffolded component.
TagBadge badge Direct port; variants extended to cover the 5 severities (stock only has default/secondary/destructive/outline).

All components keep the existing Nych* global-registration convention via createNychthemeron() — the installer gains additional entries for compound sub-components (e.g. NychDialogTrigger, NychSelectItem).

Testing strategy

  • packages/library/tests/lib.spec.ts (entirely PrimeVue-pt-injection-specific, using a callSetup helper that simulates the old engine's render function) is deleted and replaced with per-component test files that mount the real compound components via @vue/test-utils and assert rendered output/classes/v-model behavior/disabled state directly — no engine-internals testing since there's no engine.
  • packages/library/tests/engine.spec.ts is deleted along with the engine it tests.

Storybook / playground

  • All 13 .stories.ts files are rewritten to the new compound API; Select.stories.ts, Dialog.stories.ts, and Card.stories.ts see the largest rewrites since their public shape changes most.
  • .storybook/preview.ts drops the app.use(PrimeVue, { unstyled: true }) setup call; the @nychthemeron/library/theme import path is kept but now resolves to Tailwind-built CSS instead of hand-written component CSS.

Build order

  1. Foundation — wire up Tailwind v4 (@tailwindcss/vite in both packages), write the @theme token-mapping block, run shadcn-vue init to scaffold src/lib/utils.ts and CLI config, delete packages/library/src/engine and the primevue dependency.
  2. Simple components — Button, Input, Textarea, Switch, Badge, re-export LoadingIcon unchanged.
  3. Grouped/compound-lite — Checkbox + hand-built CheckboxGroup, RadioGroup + RadioGroupItem, Card, Alert.
  4. Complex compound — Select, Dialog.
  5. Cleanup — rewrite all Storybook stories and library tests to match the new API, remove any remaining PrimeVue references from configs/CI.

Success criteria

  • primevue no longer appears in any package.json, config, or import across the repo.
  • All 13 original components have a shadcn-vue-based equivalent, installable the same way as today (import { X } from '@nychthemeron/library', registered via createNychthemeron()).
  • Hades and Apollo themes both render correctly across every component with no regression in the token values used.
  • bun run test, bun run type-check, and bun run lint all pass.
  • Storybook (bun run dev in packages/playground) starts and every story renders without console errors, using the new compound APIs.