10 KiB
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
primevueas 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 smallercn()utility (clsx+tailwind-merge) that shadcn-vue itself generates, plus sharedcvavariant 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 ofpackages/library; dependency ofpackages/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 intopackages/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 bothpackages/library/vite.config.tsandpackages/playground/vite.config.ts/ Storybook Vite config. - A
@themeblock maps existing Hades/Apollo custom properties onto the canonical color slots shadcn-vue's generatedcvavariants 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/--warntokens and their-fgcounterparts).
- 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 throughvar()at paint time, so the Storybook theme toolbar keeps working without modification. - Global concerns (font-family tokens, base box-sizing reset,
:focus-visiblering,:disabledopacity) remain in one small global CSS file, imported once. All per-component layout/spacing/color styling that used to live innychthemeron.css/theme.cssmoves into Tailwind utility classes andcvavariants inside each component's own.vuefile — the shadcn-vue idiom. packages/libraryexports (./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.tsexportingcn()(clsx+tailwind-merge), used inside every component for merging caller-providedclassprops with internal variant classes. - Where a color/severity mapping is genuinely shared across components (e.g. the 5-way severity set used by both
BadgeandAlert), factor it into one sharedcvabase (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. |
InputText → Input |
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-group → RadioGroup, RadioGroupItem |
Direct CLI equivalent; naming changes to match. |
ToggleSwitch → Switch |
switch |
Direct port, renamed. |
Select |
select → Select, SelectTrigger, SelectValue, SelectContent, SelectItem |
Compound API replaces the options/optionLabel/optionValue props; consumers write <SelectItem> children. |
Card |
card → Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
#subtitle slot → CardDescription; header-image use case becomes a plain child placed before CardHeader. |
Dialog |
dialog → Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose |
Current headerActions (multiple header buttons, not just close) becomes extra children placed in DialogHeader alongside DialogClose. |
Message → Alert |
alert → Alert, 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. |
Tag → Badge |
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 acallSetuphelper 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-utilsand assert rendered output/classes/v-modelbehavior/disabled state directly — no engine-internals testing since there's no engine.packages/library/tests/engine.spec.tsis deleted along with the engine it tests.
Storybook / playground
- All 13
.stories.tsfiles are rewritten to the new compound API;Select.stories.ts,Dialog.stories.ts, andCard.stories.tssee the largest rewrites since their public shape changes most. .storybook/preview.tsdrops theapp.use(PrimeVue, { unstyled: true })setup call; the@nychthemeron/library/themeimport path is kept but now resolves to Tailwind-built CSS instead of hand-written component CSS.
Build order
- Foundation — wire up Tailwind v4 (
@tailwindcss/vitein both packages), write the@themetoken-mapping block, runshadcn-vue initto scaffoldsrc/lib/utils.tsand CLI config, deletepackages/library/src/engineand theprimevuedependency. - Simple components — Button, Input, Textarea, Switch, Badge, re-export LoadingIcon unchanged.
- Grouped/compound-lite — Checkbox + hand-built CheckboxGroup, RadioGroup + RadioGroupItem, Card, Alert.
- Complex compound — Select, Dialog.
- Cleanup — rewrite all Storybook stories and library tests to match the new API, remove any remaining PrimeVue references from configs/CI.
Success criteria
primevueno longer appears in anypackage.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 viacreateNychthemeron()). - 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, andbun run lintall pass.- Storybook (
bun run devinpackages/playground) starts and every story renders without console errors, using the new compound APIs.