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

7.5 KiB

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 (NychInputTextNychInput) — rename only, v-model, type, placeholder, autocomplete unchanged. fluidclass="w-full".

Textarea (NychTextarea) — same shape as Input, fluidclass="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 NychMessageNychAlert 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.