# Nychthemeron shadcn-vue Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port Mercury UI from the old PrimeVue-based `@nychthemeron/library@0.0.1` to the new shadcn-vue-based `@nychthemeron/library@0.0.1` (same version, contents replaced on the registry — already installed and lockfile-consistent as of this plan).
**Architecture:** Two phases. Phase 1 fixes build/plugin wiring so the app compiles and runs against the new library (no view changes yet — the app will render unstyled/broken between Phase 1 and the first view task, which is expected). Phase 2 migrates each view file natively to the new component API, one file at a time, each independently testable via type-check + manual browser verification.
**Tech Stack:** Vue 3.5, Vite 5, TypeScript, `@nychthemeron/library` (shadcn-vue + Tailwind v4 + CVA under the hood, consumed only through its `Nych*` global components — this app never imports shadcn-vue/Tailwind/CVA directly).
> **Amendment (during Task 1 execution):** the library moved again, from
> `0.0.1` to `0.0.3`, while this plan was being executed (maintainer-owned
> registry, confirmed intentional). Two things changed that affect Task 1
> only — Phase 2's component-level API (Button/Dialog/Select/Alert props)
> is unaffected:
> - The `"./theme"` export subpath was dropped. `main.ts` must import
> `@nychthemeron/library/style` instead of `.../theme` — same
> `tailwind.css` content, different subpath.
> - The library's `"."` export declares a `"development"` condition
> pointing at `./src/index.ts`, which isn't in the published files
> (`dist/`, `src/assets/`, `src/components/` only). Vite's dev server
> picks that condition first and fails to resolve the bare
> `@nychthemeron/library` specifier. `vite.config.ts` needs a
> `resolve.alias` entry anchored to the exact bare specifier (regex
> `/^@nychthemeron\/library$/`, not a plain string — plain-string
> aliases prefix-match and break the `/style`/`/components` subpath
> imports) pointing straight at `./node_modules/@nychthemeron/library/dist/index.js`.
>
> Both fixes already landed in commit `86a715e` on `main`. Tasks 2+ can
> assume `bun run dev` and `bun run build` both work cleanly at the
> config/plugin level — nothing in this amendment affects Phase 2 view
> migrations.
## Global Constraints
- Every `Nych*` tag is a **globally registered component** via `app.use(createNychthemeron())` in `src/main.ts` — view files never `import` them individually. Don't add per-file imports.
- Button `variant` values: `primary` (default), `secondary`, `info`, `success`, `warning`, `danger`. `size` values: `default`, `sm`, `lg`, `icon`.
- Alert `variant` values: `info` (default), `success`, `warning`, `danger`, `secondary`.
- There is no `fluid` prop anywhere in the new library — full-width is always `class="w-full"`.
- There is no `label` prop on `NychButton` — button text is the default slot.
- `NychDialog` is `v-model:open` (boolean), not `v-model:visible`. Header text goes in `...`, not a `header` prop. Width goes on `NychDialogContent`'s `class`, not a `style` attribute on `NychDialog`.
- `NychSelect` takes a plain string `v-model` and `NychSelectItem` children — there is no `:options`/`optionLabel`/`optionValue` array API, and **values must be strings** (no boolean/number option values).
- No test framework exists in this repo (`package.json` has no `test` script). Verification is `bun run build` (runs `vue-tsc --noEmit && vite build`) for type-correctness, plus manual browser verification for visual/behavioral correctness — do not add a test framework as part of this plan (out of scope, not requested).
- Run all commands from `/home/mcpeakml/code/rust/Mercury/ui`.
---
### Task 1: Foundation — build and plugin wiring
**Files:**
- Modify: `src/main.ts`
- Modify: `vite.config.ts`
- Modify: `package.json`
- Modify: `src/assets/main.css`
**Interfaces:**
- Produces: a working `bun run dev` server with the new `@nychthemeron/library` theme CSS loaded and no PrimeVue references left anywhere in the app. Every later task depends on this.
- [ ] **Step 1: Remove PrimeVue from `src/main.ts`**
Current content:
```ts
import { createApp } from "vue";
import '@nychthemeron/library/theme'
import './assets/main.css'
import './stores/theme'
import { createPinia } from "pinia";
import PrimeVue from "@primevue/core/config";
import { createNychthemeron } from "@nychthemeron/library";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, { unstyled: true });
app.use(createNychthemeron());
app.mount("#app");
```
Replace with:
```ts
import { createApp } from "vue";
import '@nychthemeron/library/theme'
import './assets/main.css'
import './stores/theme'
import { createPinia } from "pinia";
import { createNychthemeron } from "@nychthemeron/library";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(createNychthemeron());
app.mount("#app");
```
- [ ] **Step 2: Add the Tailwind v4 Vite plugin and drop the PrimeVue chunk in `vite.config.ts`**
Current content:
```ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/admin": "http://localhost:3000",
},
},
build: {
outDir: "dist",
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
primevue: ["@primevue/core"],
nychthemeron: ["@nychthemeron/library"],
},
entryFileNames: "js/[name]-[hash].js",
chunkFileNames: "js/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
},
},
},
});
```
Replace with:
```ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/admin": "http://localhost:3000",
},
},
build: {
outDir: "dist",
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
nychthemeron: ["@nychthemeron/library"],
},
entryFileNames: "js/[name]-[hash].js",
chunkFileNames: "js/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
},
},
},
});
```
- [ ] **Step 3: Add direct devDependencies and bump the `vue` peer range in `package.json`**
Current content:
```json
{
"name": "mercury-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@nychthemeron/library": "^0.0.1",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}
```
Replace with:
```json
{
"name": "mercury-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@nychthemeron/library": "^0.0.1",
"pinia": "^2.1.0",
"vue": "^3.5.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@vitejs/plugin-vue": "^5.0.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}
```
- [ ] **Step 4: Run `bun install` to sync the lockfile with the `package.json` edits**
Run: `bun install`
Expected: exits 0, no version conflicts reported. (`@tailwindcss/vite`, `tailwindcss`, and `vue@3.5.x` are already present in `bun.lock` as transitive dependencies of `@nychthemeron/library`, so this should only re-link them as direct deps, not download anything new.)
- [ ] **Step 5: Remove the dead unstyled-PrimeVue overrides from `src/assets/main.css`**
Find:
```css
.nych-dialog {
min-width: 500px;
}
[class^="nych-button"] {
box-shadow: unset !important;
justify-content: center !important;
}
[class^="nych-button"]:hover {
box-shadow: 0 0 5px var(--text-high);
}
.nych-loading-icon .wreath {
transform-box: fill-box;
transform-origin: center;
}
```
Replace with:
```css
.nych-loading-icon .wreath {
transform-box: fill-box;
transform-origin: center;
}
```
(`.nych-dialog` and `[class^="nych-button"]` matched classnames the old unstyled-PrimeVue passthrough emitted. The new `NychDialog`/`NychButton` don't emit any classname starting with `nych-dialog`/`nych-button` — they use Tailwind utility classes and `data-slot` attributes instead — so these two rules can no longer match anything. `.nych-loading-icon .wreath` stays: `NychLoadingIcon`'s markup is unchanged in the new library.)
- [ ] **Step 6: Verify the app boots**
Run: `bun run dev` (leave running in the background, or run and check output then stop it)
Expected: Vite starts with no errors. Open `http://localhost:5173` (or whatever port Vite prints) in a browser — the login page should load without console errors. It will look broken/unstyled in places until Phase 2 migrates `Login.vue` and the other views (raw `Nych*` global components render, but with old PrimeVue-style props like `label`/`fluid`/`header` that the new components silently ignore, so buttons show no text and dialogs show no visible chrome) — that's expected at this point, not a regression to fix here.
- [ ] **Step 7: Commit**
```bash
git add src/main.ts vite.config.ts package.json bun.lock src/assets/main.css
git commit -m "Wire Mercury UI build to the shadcn-vue nychthemeron library"
```
---
### Task 2: Migrate `src/views/Login.vue`
**Files:**
- Modify: `src/views/Login.vue`
**Interfaces:**
- Consumes: `NychInput` (rename of `NychInputText`), `NychButton` — both globally registered per Task 1.
- [ ] **Step 1: Update the template**
Find:
```html
{{ error }}
```
Replace with:
```html
{{ error }}
Sign In
```
- [ ] **Step 2: Update the leftover `
```
Replace with:
```css
```
(Note: the old CSS also had `.nych-select[data-p~='focus']` / `.nych-select[data-p~='disabled']` / `.nych-select-option[data-p-focused='true']` rules — omitted here because this component's template never sets those `data-p` attributes, so they were always dead code for this usage.)
- [ ] **Step 2: Type-check**
Run: `bun run build`
Expected: no TypeScript errors referencing `MethodSelect.vue` (this task is CSS-only, no template/script changes, so this should already pass).
- [ ] **Step 3: Manual browser check**
This component is only used from `Blacklist.vue`, which hasn't been migrated yet at this point in the plan — skip the browser check here and verify it as part of Task 7 (`Blacklist.vue`) instead, where it's actually reachable in the running app.
- [ ] **Step 4: Commit**
```bash
git add src/views/admin/MethodSelect.vue
git commit -m "Restyle MethodSelect.vue for the new theme CSS"
```
---
### Task 4: Migrate `src/views/admin/Cache.vue`
**Files:**
- Modify: `src/views/admin/Cache.vue`
- [ ] **Step 1: Update the template**
Find:
```html
```
Replace with:
```html
RefreshFlush Cache
```
- [ ] **Step 2: Type-check**
Run: `bun run build`
Expected: no errors referencing `Cache.vue`.
- [ ] **Step 3: Manual browser check**
Navigate to the Query Cache admin page. Verify: "Refresh" and "Flush Cache" buttons show their text, Refresh reloads stats, Flush Cache prompts a confirm dialog and clears stats on confirm.
- [ ] **Step 4: Commit**
```bash
git add src/views/admin/Cache.vue
git commit -m "Migrate Cache.vue to the shadcn-vue nychthemeron components"
```
---
### Task 5: Migrate `src/views/admin/Cors.vue`
**Files:**
- Modify: `src/views/admin/Cors.vue`
- [ ] **Step 1: Update the template**
Find:
```html
```
Replace with:
```html
+ Add Origin
```
Find:
```html
```
Replace with:
```html
Delete
```
Find:
```html
```
Replace with:
```html
Add CORS Origin
```
- [ ] **Step 2: Type-check**
Run: `bun run build`
Expected: no errors referencing `Cors.vue`.
- [ ] **Step 3: Manual browser check**
Navigate to CORS Origins. Verify: "+ Add Origin" opens a dialog titled "Add CORS Origin" with a visible border/backdrop, the input accepts text, "Add Origin" is disabled until the field is non-empty, submitting adds a row and closes the dialog, and the per-row "Delete" button removes a row after confirm.
- [ ] **Step 4: Commit**
```bash
git add src/views/admin/Cors.vue
git commit -m "Migrate Cors.vue to the shadcn-vue nychthemeron components"
```
---
### Task 6: Migrate `src/views/admin/Queries.vue`
**Files:**
- Modify: `src/views/admin/Queries.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
```
Replace with:
```html
+ New Query
```
Find:
```html
```
Replace with:
```html
EditDelete
```
- [ ] **Step 2: Update the create dialog**
Find:
```html
```
Replace with:
```html
New Query
```
- [ ] **Step 3: Update the edit dialog**
Find:
```html
```
Replace with:
```html
Edit — {{ editIdentifier }}
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Queries.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to Query Registry. Verify: "+ New Query" opens a dialog, the SQL textarea is 12 rows tall and full width, saving adds a row; per-row "Edit" opens a dialog titled "Edit — <identifier>" pre-filled with the SQL and description, saving updates the row; "Delete" removes a row after confirm.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Queries.vue
git commit -m "Migrate Queries.vue to the shadcn-vue nychthemeron components"
```
---
### Task 7: Migrate `src/views/admin/Blacklist.vue`
This file has the one boolean-valued `NychSelect` in the app (`Status: Active/Disabled`) — the new `NychSelect` only supports string `v-model`/`value`, so `editForm.active` needs to become a string internally, converted back to boolean at the API-call boundary.
**Files:**
- Modify: `src/views/admin/Blacklist.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
```
Replace with:
```html
+ Add Pattern
```
Find:
```html
```
Replace with:
```html
EditDelete
```
- [ ] **Step 2: Update the edit dialog**
Find:
```html
```
Replace with:
```html
Edit Blacklist Entry
```
- [ ] **Step 3: Update the create dialog**
Find:
```html
```
Replace with:
```html
New Blacklist Entry
```
- [ ] **Step 4: Convert `editForm.active` from boolean to string in the script**
Find:
```ts
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: true })
```
Replace with:
```ts
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: 'true' })
```
Find:
```ts
function openEdit(e: any) {
editId.value = e.id
editForm.value = {
pattern: e.pattern,
methods: stringToMethods(e.method),
reason: e.reason ?? '',
bypass_mask: e.bypass_mask ?? '',
active: e.active,
}
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/blacklist/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: editForm.value.pattern,
method: methodsToString(editForm.value.methods),
reason: editForm.value.reason || null,
bypass_mask: editForm.value.bypass_mask || null,
active: editForm.value.active,
}),
})
showEdit.value = false
load()
}
```
Replace with:
```ts
function openEdit(e: any) {
editId.value = e.id
editForm.value = {
pattern: e.pattern,
methods: stringToMethods(e.method),
reason: e.reason ?? '',
bypass_mask: e.bypass_mask ?? '',
active: String(e.active),
}
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/blacklist/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: editForm.value.pattern,
method: methodsToString(editForm.value.methods),
reason: editForm.value.reason || null,
bypass_mask: editForm.value.bypass_mask || null,
active: editForm.value.active === 'true',
}),
})
showEdit.value = false
load()
}
```
- [ ] **Step 5: Type-check**
Run: `bun run build`
Expected: no errors referencing `Blacklist.vue`.
- [ ] **Step 6: Manual browser check**
Navigate to Route Blacklist. Verify: "+ Add Pattern" and per-row "Edit"/"Delete" all show text and work; the `MethodSelect` multi-select dropdown (checked in Task 3) now visually matches the rest of the form inside these dialogs; the edit dialog's "Status" select shows "Active"/"Disabled", changing it and saving persists the new value (check the row's status badge flips), and a fresh entry defaults to "Active".
- [ ] **Step 7: Commit**
```bash
git add src/views/admin/Blacklist.vue
git commit -m "Migrate Blacklist.vue to the shadcn-vue nychthemeron components"
```
---
### Task 8: Migrate `src/views/admin/Permissions.vue`
**Files:**
- Modify: `src/views/admin/Permissions.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
```
Replace with:
```html
+ New Permission
```
Find:
```html
```
Replace with:
```html
EditDelete
```
- [ ] **Step 2: Update the edit dialog**
Find:
```html
```
Replace with:
```html
Edit — {{ editForm.name }}
```
- [ ] **Step 3: Update the create dialog**
Find:
```html
```
Replace with:
```html
New Permission
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Permissions.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to Permissions. Verify: create/edit dialogs open with visible titles, inputs are full-width, save/create both work and refresh the table, delete works after confirm.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Permissions.vue
git commit -m "Migrate Permissions.vue to the shadcn-vue nychthemeron components"
```
---
### Task 9: Migrate `src/views/admin/Users.vue`
**Files:**
- Modify: `src/views/admin/Users.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
```
Replace with:
```html
+ New User
```
Find:
```html
```
Replace with:
```html
EditDelete
```
- [ ] **Step 2: Update the edit dialog**
Find:
```html
```
Replace with:
```html
Edit — {{ editForm.username }}
```
- [ ] **Step 3: Update the create dialog**
Find:
```html
```
Replace with:
```html
New User
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Users.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to Users. Verify: create dialog's Role select shows a placeholder until a role is picked, and the hint text below updates as you pick different roles; edit dialog's Role select is pre-populated with the user's current role and its hint matches; save/create/delete all work.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Users.vue
git commit -m "Migrate Users.vue to the shadcn-vue nychthemeron components"
```
---
### Task 10: Migrate `src/views/admin/Cdn.vue`
**Files:**
- Modify: `src/views/admin/Cdn.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
```
Replace with:
```html
+ Add Object
```
Find:
```html
```
Replace with:
```html
EditDelete
```
- [ ] **Step 2: Update the create dialog**
Find:
```html
```
Replace with:
```html
Upload CDN Object
```
- [ ] **Step 3: Update the edit dialog**
Find:
```html
```
Replace with:
```html
Edit — {{ editKey }}
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Cdn.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to CDN Objects. Verify: "+ Add Object" opens the upload dialog, "Upload" stays disabled until a file is picked, picking a file and submitting adds a row; per-row "Edit" opens pre-filled, saving updates the row; "Delete" removes a row after confirm.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Cdn.vue
git commit -m "Migrate Cdn.vue to the shadcn-vue nychthemeron components"
```
---
### Task 11: Migrate `src/views/admin/Tables.vue`
The largest file — three dialogs (create, read-only inspect, drop-with-confirmation) plus a per-row column-type `NychSelect` inside a `v-for`.
**Files:**
- Modify: `src/views/admin/Tables.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
```
Replace with:
```html
+ New Table
```
Find:
```html
```
Replace with:
```html
InspectDrop
```
- [ ] **Step 2: Update the create dialog (header, inputs, and the per-column type select)**
Find:
```html
```
Replace with:
```html
New Table
```
- [ ] **Step 3: Update the inspect dialog**
Find:
```html
```
- [ ] **Step 5: Type-check**
Run: `bun run build`
Expected: no errors referencing `Tables.vue`.
- [ ] **Step 6: Manual browser check**
Navigate to Tables. Verify: "+ New Table" opens the create dialog; adding a column shows a type `NychSelect` per row (each opens independently, doesn't affect other rows), "Create Table" is disabled until name + at least one column are set, submitting creates the table and immediately opens its Inspect dialog; per-row "Inspect" shows schema + sample data read-only; "Drop" shows the warning banner when rows exist, and the "Drop Table" button stays disabled until the typed confirmation exactly matches the table name.
- [ ] **Step 7: Commit**
```bash
git add src/views/admin/Tables.vue
git commit -m "Migrate Tables.vue to the shadcn-vue nychthemeron components"
```
---
### Task 12: Migrate `src/views/admin/ApiKeys.vue`
Last file — includes the one `NychMessage` → `NychAlert` conversion, and the non-dismissible "key reveal" dialog that has no direct equivalent in the new library.
> **Amendment (post-implementation):** Step 3's original `onRevealOpenChange`
> guard (below) shipped in commit `b742f1f` but was Critical-flagged by task
> review as a no-op: the library's Dialog drives visibility off an internal
> ref that its dismiss handler sets directly, decoupled from the parent's
> `open` prop via `useVModel`'s passive mode — the guard never actually
> changed that prop, so the internal ref never resynced back to `true` and
> the dialog would silently stay closed after any outside-click/Escape.
>
> The shipped fix (commit `0f77a5f`, independently verified against the
> library's source and compiled bundle) instead uses standard
> `v-model:open="showReveal"` plus a capture-phase `document` `pointerdown`
> listener that calls `event.preventDefault()` for clicks outside
> `[data-slot="dialog-content"]` — this preempts a `defaultPrevented` check
> the library's own dismiss handler already makes for outside-clicks, so it
> blocks that path with no flicker. Escape has no equivalent hook and still
> closes the dialog early; the maintainer explicitly chose to accept that
> gap rather than add a flicker-based workaround for it too.
>
> **This depends on the library's internal `data-slot="dialog-content"`
> attribute** — re-check this dialog specifically if `@nychthemeron/library`
> is ever bumped again. See `src/views/admin/ApiKeys.vue`'s
> `blockOutsideDismiss` for the current implementation.
**Files:**
- Modify: `src/views/admin/ApiKeys.vue`
- [ ] **Step 1: Update the header button and table action button**
Find:
```html
```
Replace with:
```html
+ New Key
```
Find:
```html
```
Replace with:
```html
Revoke
```
- [ ] **Step 2: Update the create dialog**
Find:
```html
```
Replace with:
```html
New API Key
```
- [ ] **Step 3: Update the key-reveal dialog — `NychMessage` → `NychAlert`, and make it non-dismissible**
The old `:closable="false"` prevented the user from closing this dialog any way other than clicking "I've saved the key". The new `NychDialogContent` accepts `:show-close-button="false"` to drop the X button, but its overlay/Escape dismiss isn't independently disableable — so we also ignore `update:open` events that would close it, via a guarded handler.
Find:
```html
Copy this key now — it will not be shown again.
{{ newKey }}
```
Replace with:
```html
API Key Created
Copy this key now — it will not be shown again.
{{ newKey }}
I've saved the key
```
- [ ] **Step 4: Add the `onRevealOpenChange` guard to the script**
Find:
```ts
function closeReveal() {
showReveal.value = false
newKey.value = ''
}
```
Replace with:
```ts
function closeReveal() {
showReveal.value = false
newKey.value = ''
}
// The reveal dialog must only close via the "I've saved the key" button
// (closeReveal), never via outside-click/Escape — ignore any attempt to
// set it back to false that didn't go through that path.
function onRevealOpenChange(open: boolean) {
if (open) showReveal.value = true
}
```
- [ ] **Step 5: Type-check**
Run: `bun run build`
Expected: no errors referencing `ApiKeys.vue`.
- [ ] **Step 6: Manual browser check**
Navigate to API Keys. Verify: "+ New Key" dialog's Role select works and its hint updates; submitting opens the "API Key Created" dialog with a visible warning alert (amber/warning-colored, not the default info-blue), no X close button in the corner, and clicking outside the dialog or pressing Escape does **not** close it; clicking the copy icon copies the key and shows a checkmark for 2 seconds; clicking "I've saved the key" closes it and the new key appears in the table; per-row "Revoke" works after confirm.
- [ ] **Step 7: Commit**
```bash
git add src/views/admin/ApiKeys.vue
git commit -m "Migrate ApiKeys.vue to the shadcn-vue nychthemeron components"
```
---
### Task 13: Final full-app verification
**Files:** none (verification only)
- [ ] **Step 1: Full type-check and production build**
Run: `bun run build`
Expected: exits 0 with no TypeScript errors and a `dist/` build produced.
- [ ] **Step 2: Full click-through**
Run: `bun run dev`, log in, and walk every admin page (Cache, Cors, Queries, Blacklist, Permissions, Users, Cdn, Tables, ApiKeys) — for each, exercise its create, edit (where applicable), and delete/revoke flow once. Confirm the theme toggle (`useTheme().toggle()`, wired in `Layout.vue`) still switches between `apollo`/`hades` correctly across all the new components (dialogs, selects, alerts should all pick up the new theme's colors immediately, since they're driven by the same `data-theme` attribute and CSS custom properties as before).
- [ ] **Step 3: Grep for anything left behind**
Run: `grep -rn "primevue" src/ vite.config.ts package.json --include="*.vue" --include="*.ts" --include="*.json" -i`
Expected: no matches.
Run: `grep -rn "NychInputText\|severity=\|v-model:visible\|fluid\b\|label=\"" src/ --include="*.vue"`
Expected: no matches (all old-API usages converted). If this turns up hits outside the 11 files this plan touched, treat that as a signal a call site was missed during the original inventory — fix it before closing out.
- [ ] **Step 4: Commit (only if Step 3 found and fixed something; otherwise nothing to commit)**
## Post-implementation note: what's unverified
This migration shipped with `bun run build` clean across the whole app and
every view resolving under the dev server, but no automated tests (none
exist in this repo) and no live browser click-through (no chromium-cli/
Playwright/Puppeteer available in the environment this was built in). A
human should do a manual pass covering, at minimum:
- The theme toggle (`useTheme().toggle()` in `Layout.vue`) across all the
new components — dialogs, selects, alerts should all repaint correctly
between `apollo`/`hades`.
- **The ApiKeys reveal dialog's outside-click-blocked behavior specifically**
— the highest-value thing to check by hand, since it's the one piece of
runtime DOM-event logic in this migration (everything else is declarative
template conversion). Confirm clicking outside the "API Key Created"
dialog does nothing, and confirm Escape still closes it (accepted gap).
- One create/edit/delete (or revoke) cycle per admin view, per the
per-task "Manual browser check" notes throughout this plan.