Mercury/docs/superpowers/specs/2026-07-15-nychthemeron-shadcn-migration-design.md
Matthew L McPeak dd700d02ad
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m50s
ci / publish (push) Successful in 3m9s
Inital Commit
2026-07-16 12:36:14 -04:00

172 lines
7.5 KiB
Markdown

# Migrate Mercury UI to the shadcn-vue version of @nychthemeron/library
## Background
`@nychthemeron/library` was rebuilt from PrimeVue (unstyled mode + a custom
`ThemeEngine` passthrough layer) to shadcn-vue + Tailwind v4. PrimeVue moved
to a paid licensing model, so the old build was replaced in place under the
same `0.0.1` version on the private registry (`git.mcpeakdev.com`) rather
than published as a new version — confirmed intentional by the maintainer.
The consuming app (`Mercury/ui`) still targets the old PrimeVue-era API
everywhere: `main.ts` wires up the PrimeVue plugin, and 10 admin views plus
`Login.vue` use globally-registered `Nych*` components with PrimeVue-style
props (`label`, `severity`, `fluid`, `v-model:visible`, `:options`/
`optionLabel`/`optionValue`). None of that matches the new library's API, so
this is a real port, not a version bump.
## Scope
**In scope:** everything needed to get Mercury UI running correctly against
the new library — build/plugin wiring, and a native rewrite of every view
that uses a `Nych*` component.
**Out of scope:** any new features, any styling changes beyond what's needed
to preserve current look/behavior, and the library's own source (it's an
external package on a separate registry).
## Current usage inventory
| File | Button | InputText | Dialog | Select | Textarea | Message |
|---|---|---|---|---|---|---|
| Login.vue | 1 | 2 | | | | |
| admin/ApiKeys.vue | 4 | 2 | 2 | 1 | | 1 |
| admin/Blacklist.vue | 5 | 6 | 2 | 1 | | |
| admin/Cache.vue | 2 | | | | | |
| admin/Cdn.vue | 5 | 6 | 2 | | | |
| admin/Cors.vue | 3 | 1 | 1 | | | |
| admin/Permissions.vue | 5 | 5 | 2 | | | |
| admin/Queries.vue | 5 | 3 | 2 | | 2 | |
| admin/Tables.vue | 7 | 3 | 3 | 1 | | |
| admin/Users.vue | 5 | 4 | 2 | 2 | | |
| **Total** | **42** | **32** | **16** | **5** | **2** | **1** |
## Component API mapping
**Button** (`NychButton`) — low friction, variant names carry over almost
1:1:
- Content moves from `label="X"` prop to default slot: `<NychButton>X</NychButton>`
- `severity="danger"``variant="danger"` (also: `primary` (default),
`secondary`, `info`, `success`, `warning`, `danger`)
- `size="small"``size="sm"` (also: `default`, `lg`, `icon`)
- `:loading`, `:disabled` — unchanged
- `fluid` prop is gone — use `class="w-full"`
**Input** (`NychInputText` → `NychInput`) — rename only, `v-model`,
`type`, `placeholder`, `autocomplete` unchanged. `fluid``class="w-full"`.
**Textarea** (`NychTextarea`) — same shape as Input, `fluid`
`class="w-full"`.
**Dialog** — flat component becomes a compound one:
```
<!-- old -->
<NychDialog v-model:visible="show" header="Title" :modal="true" :draggable="false">
...body...
</NychDialog>
<!-- new -->
<NychDialog v-model:open="show">
<NychDialogContent>
<NychDialogHeader><NychDialogTitle>Title</NychDialogTitle></NychDialogHeader>
...body...
</NychDialogContent>
</NychDialog>
```
- `:closable="false"` (used once, in ApiKeys.vue's key-reveal dialog, to
force the user to acknowledge before dismissing) → pass
`:show-close-button="false"` on `NychDialogContent` to drop the X button,
and ignore attempts to close via `@update:open="(v) => { if (v) show = v }"`
so outside-click/Escape can't dismiss it either.
- `:draggable="false"` — no-op, the new Dialog isn't draggable to begin with.
- Width customization (`style="width: min(560px, 95vw)"`) — pass through as
`class` on `NychDialogContent` since it accepts a `class` prop merged via
`cn()`.
**Select** — flat `:options` array becomes compound children:
```
<!-- old -->
<NychSelect v-model="x" :options="ROLES" optionLabel="label" optionValue="value" placeholder="Select a role" fluid />
<!-- new -->
<NychSelect v-model="x">
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role" /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
```
**Message → Alert** — no `NychMessage` in the new library; `NychAlert` is
the closest analog:
- `severity="warn"``variant="warning"` (also: `info` (default),
`success`, `danger`, `secondary`)
- Body goes in the default slot, same as before.
## Phase 1 — Foundation (build/plugin wiring)
1. **`src/main.ts`** — remove the `@primevue/core/config` import and
`app.use(PrimeVue, { unstyled: true })` call. Keep
`import '@nychthemeron/library/theme'` and `app.use(createNychthemeron())`
unchanged (both still resolve, just point at new content).
2. **`vite.config.ts`** — add the `@tailwindcss/vite` plugin (required
because the library now ships source CSS with `@import "tailwindcss"`
that must be processed at build time, not a precompiled stylesheet).
Replace the `primevue: ["@primevue/core"]` manual chunk with nothing
(package no longer exists).
3. **`package.json`** — add `tailwindcss` and `@tailwindcss/vite` as direct
devDependencies (currently only reachable transitively through
`@nychthemeron/library`, which is fragile) at the versions already
resolved in `bun.lock`. Bump `vue` from `^3.4.0` to `^3.5.0` to match the
library's peer requirement.
4. **`src/assets/main.css`** — leave the design-token-based custom classes
(`.table-card`, `.data-table`, `.dialog-form`, `.gold-rule`,
`.loading-overlay`, etc.) as-is; they key off CSS custom properties
(`--surface-1`, `--primary`, ...) which kept the same names and values in
the new theme. Remove the dead overrides tied to the old unstyled-PrimeVue
class names: `.nych-dialog { min-width: 500px }`,
`[class^="nych-button"] { ... }`, and re-check `.nych-loading-icon .wreath`
still applies (the new `NychLoadingIcon` keeps the same class name/markup,
so this one likely stays).
5. Verify with `bun run dev` that the app boots without console errors
before touching any view.
## Phase 2 — View-by-view native migration
Migrate one file at a time, in this order (simplest/lowest-risk first, so
mistakes in the component mapping surface early on a small file rather than
a large one):
1. `Login.vue` (3 call sites, no Dialog/Select — good smoke test)
2. `admin/Cache.vue` (2 call sites, Button only)
3. `admin/Cors.vue`
4. `admin/Queries.vue`
5. `admin/Blacklist.vue`
6. `admin/Permissions.vue`
7. `admin/Users.vue`
8. `admin/Cdn.vue`
9. `admin/Tables.vue`
10. `admin/ApiKeys.vue` (has the one `NychMessage``NychAlert` conversion
and the non-dismissible dialog pattern — do it last once that pattern is
proven elsewhere isn't needed, i.e. it's the only file needing it)
For each file: apply the mapping above, then run the app (`bun run dev`),
navigate to that view in the browser, and check the golden path (list
renders, create/edit dialog opens and submits, select options populate,
delete/revoke works) before moving to the next file. This is a visual/UI
change, so it needs to be checked in a real browser, not just type-checked.
## Verification
- `vue-tsc --noEmit` (part of `bun run build`) after each file, to catch
prop-shape mistakes the compiler can see.
- Manual browser check of each migrated view per the sequencing above.
- Final full pass: `bun run build` succeeds, and a click-through of every
admin view's create/edit/delete flow.
## Open questions / risks
- The non-dismissible-dialog pattern for `ApiKeys.vue`'s key-reveal dialog
(ignoring `update:open` on close attempts) isn't a built-in library
feature — it's an app-level workaround. If it feels fragile once
implemented, worth flagging back to the library rather than solving twice.