# 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 `) 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 `` 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. | | `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 `` 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 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.