Mercury/docs/superpowers/plans/2026-07-15-nychthemeron-shadcn-migration.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

73 KiB

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 <NychDialogHeader><NychDialogTitle>...</NychDialogTitle></NychDialogHeader>, 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:

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:

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:

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:

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:

{
  "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:

{
  "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:

.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:

.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
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:

                <div class="login-field">
                    <label>Username</label>
                    <NychInputText
                        v-model="username"
                        placeholder="username"
                        fluid
                        autocomplete="username"
                    />
                </div>
                <div class="login-field">
                    <label>Password</label>
                    <NychInputText
                        v-model="password"
                        type="password"
                        placeholder="••••••••"
                        fluid
                        autocomplete="current-password"
                    />
                </div>
                <p v-if="error" class="login-error">{{ error }}</p>
                <NychButton type="submit" :loading="loading" label="Sign In" fluid />

Replace with:

                <div class="login-field">
                    <label>Username</label>
                    <NychInput
                        v-model="username"
                        placeholder="username"
                        class="w-full"
                        autocomplete="username"
                    />
                </div>
                <div class="login-field">
                    <label>Password</label>
                    <NychInput
                        v-model="password"
                        type="password"
                        placeholder="••••••••"
                        class="w-full"
                        autocomplete="current-password"
                    />
                </div>
                <p v-if="error" class="login-error">{{ error }}</p>
                <NychButton type="submit" :loading="loading" class="w-full">Sign In</NychButton>
  • Step 2: Update the leftover <style> selector

Find:

.nych-button-primary {
    margin-top: 2px;
}

Replace with:

[data-slot="button"] {
    margin-top: 2px;
}

(The new NychButton root element carries data-slot="button" instead of a nych-button-primary classname — this preserves the same 2px top margin on the sign-in button.)

  • Step 3: Type-check

Run: bun run build Expected: no TypeScript errors referencing Login.vue. (The build may still fail on other unmigrated views — only check that Login.vue isn't in the error output.)

  • Step 4: Manual browser check

Run bun run dev, open the login page. Verify: username/password fields render full-width with visible borders, typing works, submitting with empty fields still hits the API (existing validation is server-side), the "Sign In" button shows text and a loading spinner while loading is true, and a failed login shows the red error message.

  • Step 5: Commit
git add src/views/Login.vue
git commit -m "Migrate Login.vue to the shadcn-vue nychthemeron components"

Task 3: Restyle src/views/admin/MethodSelect.vue

This is a bespoke local multi-select component (not part of the library) that styled itself entirely off classnames the old nychthemeron.css defined (.nych-select, .nych-select-label, .nych-select-dropdown, .nych-select-dropdownIcon, .nych-select-overlay, .nych-select-list, .nych-select-option). The new theme CSS doesn't define those classnames at all, so without this fix MethodSelect renders as an unstyled native button/list as soon as Task 1 lands. It stays a bespoke component — the library's NychSelect is single-value only and has no multi-select equivalent, so there's nothing to swap it for.

Files:

  • Modify: src/views/admin/MethodSelect.vue

  • Step 1: Add the missing styles into the component's existing global <style> block

Find:

<style>
/* Must be global — teleported content renders outside this component's scoped tree */
.ms-trigger {
  display: flex;
  width: 100%;
  min-width: unset;
  text-align: left;
  font-family: var(--font-sans);
  font-size: 14px;
  cursor: pointer;
}

.ms-backdrop {
  position: fixed;
  inset: 0;
  z-index: 9998;
}

.ms-panel {
  z-index: 9999;
}

.ms-chevron {
  transition: transform 0.15s ease;
}
.ms-chevron-open {
  transform: rotate(180deg);
}
</style>

Replace with:

<style>
/* Must be global — teleported content renders outside this component's scoped tree.
   These .nych-select-* rules used to come from the library's own CSS; the new
   shadcn-vue build doesn't emit them (its Select uses Tailwind utility classes
   instead), so this component now carries its own copy to keep the same look. */
.nych-select {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-width: 13rem;
  font-family: var(--font-sans);
  padding: 8px 11px;
  color: var(--text-body);
  background-color: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: 5px;
  cursor: pointer;
  transition:
    border-color 0.2s ease,
    box-shadow 0.2s ease;
}

.nych-select:hover {
  border-color: var(--border-hi);
}

.nych-select-label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nych-select-label[data-p='placeholder'] {
  color: var(--text-dim);
}

.nych-select-dropdown {
  display: flex;
  align-items: center;
  color: var(--text-muted);
}

.nych-select-dropdownIcon {
  width: 14px;
  height: 14px;
}

.nych-select-overlay {
  font-family: var(--font-sans);
  margin-top: 4px;
  background-color: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  overflow: hidden;
}

.nych-select-list {
  list-style: none;
  margin: 0;
  padding: 4px;
  max-height: 14rem;
  overflow-y: auto;
}

.nych-select-option {
  padding: 8px 11px;
  border-radius: 4px;
  color: var(--text-body);
  cursor: pointer;
  transition: background-color 0.15s ease;
}

.nych-select-option:hover {
  background-color: var(--surface-3);
}

.nych-select-option[data-p-selected='true'] {
  background-color: var(--primary);
  color: var(--primary-fg);
}

/* Must be global — teleported content renders outside this component's scoped tree */
.ms-trigger {
  display: flex;
  width: 100%;
  min-width: unset;
  text-align: left;
  font-family: var(--font-sans);
  font-size: 14px;
  cursor: pointer;
}

.ms-backdrop {
  position: fixed;
  inset: 0;
  z-index: 9998;
}

.ms-panel {
  z-index: 9999;
}

.ms-chevron {
  transition: transform 0.15s ease;
}
.ms-chevron-open {
  transform: rotate(180deg);
}
</style>

(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
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:

        <NychButton @click="load" label="Refresh" />
        <NychButton severity="danger" @click="flushCache" label="Flush Cache" />

Replace with:

        <NychButton @click="load">Refresh</NychButton>
        <NychButton variant="danger" @click="flushCache">Flush Cache</NychButton>
  • 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
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:

      <NychButton @click="showCreate = true" label="+ Add Origin" />

Replace with:

      <NychButton @click="showCreate = true">+ Add Origin</NychButton>

Find:

                  <NychButton size="small" severity="danger" @click="deleteOrigin(o.id)" label="Delete" />

Replace with:

                  <NychButton size="sm" variant="danger" @click="deleteOrigin(o.id)">Delete</NychButton>

Find:

    <NychDialog v-model:visible="showCreate" header="Add CORS Origin" :modal="true" :draggable="false" style="width: min(480px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Origin</label>
          <NychInputText v-model="form.origin" placeholder="https://app.example.com" fluid />
          <p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
        </div>
        <NychButton type="submit" fluid label="Add Origin" :disabled="!form.origin || loading" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(480px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Add CORS Origin</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Origin</label>
            <NychInput v-model="form.origin" placeholder="https://app.example.com" class="w-full" />
            <p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
          </div>
          <NychButton type="submit" class="w-full" :disabled="!form.origin || loading">Add Origin</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • 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
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:

      <NychButton @click="showCreate = true" label="+ New Query" />

Replace with:

      <NychButton @click="showCreate = true">+ New Query</NychButton>

Find:

              <NychButton size="small" @click="openEdit(q)" label="Edit" />
              <NychButton size="small" severity="danger" @click="deleteQuery(q)" label="Delete" />

Replace with:

              <NychButton size="sm" @click="openEdit(q)">Edit</NychButton>
              <NychButton size="sm" variant="danger" @click="deleteQuery(q)">Delete</NychButton>
  • Step 2: Update the create dialog

Find:

    <NychDialog v-model:visible="showCreate" header="New Query" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Identifier</label>
          <NychInputText v-model="form.identifier" placeholder="e.g. get-user-orders" fluid />
        </div>
        <div class="field">
          <label>Description <span class="optional">(optional)</span></label>
          <NychInputText v-model="form.description" placeholder="Brief description of what this query does" fluid />
        </div>
        <div class="field">
          <label>SQL Template</label>
          <NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" fluid />
          <p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
        </div>
        <NychButton type="submit" fluid label="Save Query" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(800px,95vw)]">
        <NychDialogHeader><NychDialogTitle>New Query</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Identifier</label>
            <NychInput v-model="form.identifier" placeholder="e.g. get-user-orders" class="w-full" />
          </div>
          <div class="field">
            <label>Description <span class="optional">(optional)</span></label>
            <NychInput v-model="form.description" placeholder="Brief description of what this query does" class="w-full" />
          </div>
          <div class="field">
            <label>SQL Template</label>
            <NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" class="w-full" />
            <p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
          </div>
          <NychButton type="submit" class="w-full">Save Query</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the edit dialog

Find:

    <NychDialog v-model:visible="showEdit" :header="`Edit — ${editIdentifier}`" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
      <form @submit.prevent="submitEdit" class="dialog-form">
        <div class="field">
          <label>SQL Template</label>
          <NychTextarea v-model="editForm.sql_template" :rows="12" fluid />
          <p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
        </div>
        <div class="field">
          <label>Description <span class="optional">(optional)</span></label>
          <NychInputText v-model="editForm.description" placeholder="Brief description" fluid />
        </div>
        <NychButton type="submit" fluid label="Update Query" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showEdit">
      <NychDialogContent class="w-[min(800px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Edit — {{ editIdentifier }}</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitEdit" class="dialog-form">
          <div class="field">
            <label>SQL Template</label>
            <NychTextarea v-model="editForm.sql_template" :rows="12" class="w-full" />
            <p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
          </div>
          <div class="field">
            <label>Description <span class="optional">(optional)</span></label>
            <NychInput v-model="editForm.description" placeholder="Brief description" class="w-full" />
          </div>
          <NychButton type="submit" class="w-full">Update Query</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • 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
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:

      <NychButton @click="showCreate = true" label="+ Add Pattern" />

Replace with:

      <NychButton @click="showCreate = true">+ Add Pattern</NychButton>

Find:

              <NychButton size="small" @click="openEdit(e)" label="Edit" />
              <NychButton size="small" severity="danger" @click="deleteEntry(e.id)" label="Delete" />

Replace with:

              <NychButton size="sm" @click="openEdit(e)">Edit</NychButton>
              <NychButton size="sm" variant="danger" @click="deleteEntry(e.id)">Delete</NychButton>
  • Step 2: Update the edit dialog

Find:

    <NychDialog v-model:visible="showEdit" header="Edit Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
      <form @submit.prevent="submitEdit" class="dialog-form">
        <div class="field">
          <label>Pattern</label>
          <NychInputText v-model="editForm.pattern" fluid />
        </div>
        <div class="field">
          <label>HTTP Methods <span class="optional">(none = all methods)</span></label>
          <MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
        </div>
        <div class="field">
          <label>Reason <span class="optional">(optional)</span></label>
          <NychInputText v-model="editForm.reason" fluid />
        </div>
        <div class="field">
          <label>Bypass permission mask <span class="optional">(optional)</span></label>
          <NychInputText v-model="editForm.bypass_mask" placeholder="32" fluid />
        </div>
        <div class="field">
          <label>Status</label>
          <NychSelect v-model="editForm.active" :options="[{ label: 'Active', value: true }, { label: 'Disabled', value: false }]" optionLabel="label" optionValue="value" fluid />
        </div>
        <NychButton type="submit" fluid label="Save Changes" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showEdit">
      <NychDialogContent class="w-[min(640px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Edit Blacklist Entry</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitEdit" class="dialog-form">
          <div class="field">
            <label>Pattern</label>
            <NychInput v-model="editForm.pattern" class="w-full" />
          </div>
          <div class="field">
            <label>HTTP Methods <span class="optional">(none = all methods)</span></label>
            <MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
          </div>
          <div class="field">
            <label>Reason <span class="optional">(optional)</span></label>
            <NychInput v-model="editForm.reason" class="w-full" />
          </div>
          <div class="field">
            <label>Bypass permission mask <span class="optional">(optional)</span></label>
            <NychInput v-model="editForm.bypass_mask" placeholder="32" class="w-full" />
          </div>
          <div class="field">
            <label>Status</label>
            <NychSelect v-model="editForm.active" class="w-full">
              <NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
              <NychSelectContent>
                <NychSelectItem value="true">Active</NychSelectItem>
                <NychSelectItem value="false">Disabled</NychSelectItem>
              </NychSelectContent>
            </NychSelect>
          </div>
          <NychButton type="submit" class="w-full">Save Changes</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the create dialog

Find:

    <NychDialog v-model:visible="showCreate" header="New Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Pattern</label>
          <NychInputText v-model="form.pattern" placeholder="/api/sensitive/**" fluid />
          <p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
        </div>
        <div class="field">
          <label>HTTP Methods <span class="optional">(none = all methods)</span></label>
          <MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
        </div>
        <div class="field">
          <label>Reason <span class="optional">(optional)</span></label>
          <NychInputText v-model="form.reason" placeholder="Why is this route blocked?" fluid />
        </div>
        <div class="field">
          <label>Bypass permission mask <span class="optional">(optional)</span></label>
          <NychInputText v-model="form.bypass_mask" placeholder="32" fluid />
          <p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
        </div>
        <NychButton type="submit" fluid label="Add to Blacklist" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(640px,95vw)]">
        <NychDialogHeader><NychDialogTitle>New Blacklist Entry</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Pattern</label>
            <NychInput v-model="form.pattern" placeholder="/api/sensitive/**" class="w-full" />
            <p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
          </div>
          <div class="field">
            <label>HTTP Methods <span class="optional">(none = all methods)</span></label>
            <MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
          </div>
          <div class="field">
            <label>Reason <span class="optional">(optional)</span></label>
            <NychInput v-model="form.reason" placeholder="Why is this route blocked?" class="w-full" />
          </div>
          <div class="field">
            <label>Bypass permission mask <span class="optional">(optional)</span></label>
            <NychInput v-model="form.bypass_mask" placeholder="32" class="w-full" />
            <p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
          </div>
          <NychButton type="submit" class="w-full">Add to Blacklist</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 4: Convert editForm.active from boolean to string in the script

Find:

const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: true })

Replace with:

const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: 'true' })

Find:

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:

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
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:

      <NychButton @click="showCreate = true" label="+ New Permission" />

Replace with:

      <NychButton @click="showCreate = true">+ New Permission</NychButton>

Find:

              <NychButton size="small" @click="openEdit(p)" label="Edit" />
              <NychButton size="small" severity="danger" @click="deletePermission(p.id)" label="Delete" />

Replace with:

              <NychButton size="sm" @click="openEdit(p)">Edit</NychButton>
              <NychButton size="sm" variant="danger" @click="deletePermission(p.id)">Delete</NychButton>
  • Step 2: Update the edit dialog

Find:

    <NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.name}`" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
      <form @submit.prevent="submitEdit" class="dialog-form">
        <div class="field">
          <label>Name</label>
          <NychInputText v-model="editForm.name" fluid />
        </div>
        <div class="field">
          <label>Description</label>
          <NychInputText v-model="editForm.description" fluid />
        </div>
        <NychButton type="submit" fluid label="Save Changes" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showEdit">
      <NychDialogContent class="w-[min(600px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Edit — {{ editForm.name }}</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitEdit" class="dialog-form">
          <div class="field">
            <label>Name</label>
            <NychInput v-model="editForm.name" class="w-full" />
          </div>
          <div class="field">
            <label>Description</label>
            <NychInput v-model="editForm.description" class="w-full" />
          </div>
          <NychButton type="submit" class="w-full">Save Changes</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the create dialog

Find:

    <NychDialog v-model:visible="showCreate" header="New Permission" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Name</label>
          <NychInputText v-model="form.name" placeholder="PERMISSION_NAME" fluid />
        </div>
        <div class="field">
          <label>Bit value</label>
          <NychInputText v-model="form.bit_value" placeholder="64" fluid />
          <p class="hint">Must be a power of 2 not already in use (1, 2, 4, 8, 16, 32, 64 …)</p>
        </div>
        <div class="field">
          <label>Description</label>
          <NychInputText v-model="form.description" placeholder="What this permission grants" fluid />
        </div>
        <NychButton type="submit" fluid label="Create Permission" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(600px,95vw)]">
        <NychDialogHeader><NychDialogTitle>New Permission</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Name</label>
            <NychInput v-model="form.name" placeholder="PERMISSION_NAME" class="w-full" />
          </div>
          <div class="field">
            <label>Bit value</label>
            <NychInput v-model="form.bit_value" placeholder="64" class="w-full" />
            <p class="hint">Must be a power of 2 not already in use (1, 2, 4, 8, 16, 32, 64 …)</p>
          </div>
          <div class="field">
            <label>Description</label>
            <NychInput v-model="form.description" placeholder="What this permission grants" class="w-full" />
          </div>
          <NychButton type="submit" class="w-full">Create Permission</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • 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
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:

      <NychButton @click="showCreate = true" label="+ New User" />

Replace with:

      <NychButton @click="showCreate = true">+ New User</NychButton>

Find:

              <NychButton size="small" @click="openEdit(u)" label="Edit" />
              <NychButton size="small" severity="danger" @click="deleteUser(u.id)" label="Delete" />

Replace with:

              <NychButton size="sm" @click="openEdit(u)">Edit</NychButton>
              <NychButton size="sm" variant="danger" @click="deleteUser(u.id)">Delete</NychButton>
  • Step 2: Update the edit dialog

Find:

    <NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.username}`" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
      <form @submit.prevent="submitEdit" class="dialog-form">
        <div class="field">
          <label>Username</label>
          <NychInputText v-model="editForm.username" fluid autocomplete="off" />
        </div>
        <div class="field">
          <label>New password <span class="optional">(leave blank to keep current)</span></label>
          <NychInputText v-model="editForm.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
        </div>
        <div class="field">
          <label>Role</label>
          <NychSelect v-model="editForm.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" fluid />
          <p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
        </div>
        <NychButton type="submit" fluid label="Save Changes" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showEdit">
      <NychDialogContent class="w-[min(680px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Edit — {{ editForm.username }}</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitEdit" class="dialog-form">
          <div class="field">
            <label>Username</label>
            <NychInput v-model="editForm.username" class="w-full" autocomplete="off" />
          </div>
          <div class="field">
            <label>New password <span class="optional">(leave blank to keep current)</span></label>
            <NychInput v-model="editForm.password" type="password" placeholder="••••••••" class="w-full" autocomplete="new-password" />
          </div>
          <div class="field">
            <label>Role</label>
            <NychSelect v-model="editForm.permissions_mask" class="w-full">
              <NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
              <NychSelectContent>
                <NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
              </NychSelectContent>
            </NychSelect>
            <p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
          </div>
          <NychButton type="submit" class="w-full">Save Changes</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the create dialog

Find:

    <NychDialog v-model:visible="showCreate" header="New User" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Username</label>
          <NychInputText v-model="form.username" placeholder="username" fluid autocomplete="off" />
        </div>
        <div class="field">
          <label>Password</label>
          <NychInputText v-model="form.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
        </div>
        <div class="field">
          <label>Role</label>
          <NychSelect
            v-model="selectedRole"
            :options="ROLES"
            optionLabel="label"
            optionValue="value"
            placeholder="Select a role"
            fluid
          />
          <p class="hint">{{ roleDescription }}</p>
        </div>
        <NychButton type="submit" fluid label="Create User" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(680px,95vw)]">
        <NychDialogHeader><NychDialogTitle>New User</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Username</label>
            <NychInput v-model="form.username" placeholder="username" class="w-full" autocomplete="off" />
          </div>
          <div class="field">
            <label>Password</label>
            <NychInput v-model="form.password" type="password" placeholder="••••••••" class="w-full" autocomplete="new-password" />
          </div>
          <div class="field">
            <label>Role</label>
            <NychSelect v-model="selectedRole" class="w-full">
              <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>
            <p class="hint">{{ roleDescription }}</p>
          </div>
          <NychButton type="submit" class="w-full">Create User</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • 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
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:

      <NychButton @click="showCreate = true" label="+ Add Object" />

Replace with:

      <NychButton @click="showCreate = true">+ Add Object</NychButton>

Find:

                  <NychButton size="small" @click="openEdit(o)" label="Edit" />
                  <NychButton size="small" severity="danger" @click="deleteObject(o.key)" label="Delete" />

Replace with:

                  <NychButton size="sm" @click="openEdit(o)">Edit</NychButton>
                  <NychButton size="sm" variant="danger" @click="deleteObject(o.key)">Delete</NychButton>
  • Step 2: Update the create dialog

Find:

    <NychDialog v-model:visible="showCreate" header="Upload CDN Object" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>File</label>
          <input type="file" ref="fileInput" @change="onFileChange" class="file-input" />
          <p class="hint">The filename becomes the key unless overridden below.</p>
        </div>
        <div class="field">
          <label>Key <span class="optional">(optional — defaults to filename)</span></label>
          <NychInputText v-model="form.key" placeholder="logo.png" fluid />
        </div>
        <div class="field">
          <label>Content Type <span class="optional">(optional — auto-detected)</span></label>
          <NychInputText v-model="form.content_type" placeholder="image/png" fluid />
        </div>
        <div class="field">
          <label>Description <span class="optional">(optional)</span></label>
          <NychInputText v-model="form.description" placeholder="App logo" fluid />
        </div>
        <NychButton type="submit" fluid label="Upload" :disabled="!selectedFile || loading" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(560px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Upload CDN Object</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>File</label>
            <input type="file" ref="fileInput" @change="onFileChange" class="file-input" />
            <p class="hint">The filename becomes the key unless overridden below.</p>
          </div>
          <div class="field">
            <label>Key <span class="optional">(optional — defaults to filename)</span></label>
            <NychInput v-model="form.key" placeholder="logo.png" class="w-full" />
          </div>
          <div class="field">
            <label>Content Type <span class="optional">(optional — auto-detected)</span></label>
            <NychInput v-model="form.content_type" placeholder="image/png" class="w-full" />
          </div>
          <div class="field">
            <label>Description <span class="optional">(optional)</span></label>
            <NychInput v-model="form.description" placeholder="App logo" class="w-full" />
          </div>
          <NychButton type="submit" class="w-full" :disabled="!selectedFile || loading">Upload</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the edit dialog

Find:

    <NychDialog v-model:visible="showEdit" :header="`Edit — ${editKey}`" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
      <form @submit.prevent="submitEdit" class="dialog-form">
        <div class="field">
          <label>Key</label>
          <NychInputText v-model="editForm.key" fluid />
        </div>
        <div class="field">
          <label>Content Type <span class="optional">(optional)</span></label>
          <NychInputText v-model="editForm.content_type" fluid />
        </div>
        <div class="field">
          <label>Description <span class="optional">(optional)</span></label>
          <NychInputText v-model="editForm.description" fluid />
        </div>
        <NychButton type="submit" fluid label="Save Changes" :disabled="loading" />
      </form>
    </NychDialog>

Replace with:

    <NychDialog v-model:open="showEdit">
      <NychDialogContent class="w-[min(560px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Edit — {{ editKey }}</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitEdit" class="dialog-form">
          <div class="field">
            <label>Key</label>
            <NychInput v-model="editForm.key" class="w-full" />
          </div>
          <div class="field">
            <label>Content Type <span class="optional">(optional)</span></label>
            <NychInput v-model="editForm.content_type" class="w-full" />
          </div>
          <div class="field">
            <label>Description <span class="optional">(optional)</span></label>
            <NychInput v-model="editForm.description" class="w-full" />
          </div>
          <NychButton type="submit" class="w-full" :disabled="loading">Save Changes</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • 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
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:

      <NychButton @click="showCreate = true" label="+ New Table" />

Replace with:

      <NychButton @click="showCreate = true">+ New Table</NychButton>

Find:

              <NychButton size="small" severity="secondary" @click="openInspect(t.table_name)" label="Inspect" />
              <NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)" label="Drop" />

Replace with:

              <NychButton size="sm" variant="secondary" @click="openInspect(t.table_name)">Inspect</NychButton>
              <NychButton size="sm" variant="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
  • Step 2: Update the create dialog (header, inputs, and the per-column type select)

Find:

    <!-- Create Table Dialog -->
    <NychDialog v-model:visible="showCreate" header="New Table" :modal="true" :draggable="false" style="width: min(700px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Table Name</label>
          <NychInputText v-model="createForm.name" placeholder="my_table" fluid autocomplete="off" />
          <p class="hint">Lowercase letters, numbers, and underscores only.</p>
        </div>

        <div class="columns-section">
          <div class="columns-header">
            <label>Columns</label>
            <NychButton type="button" size="small" @click="addColumn" label="+ Add Column" />
          </div>
          <div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>

          <div class="column-row header-row">
            <span>Name</span>
            <span>Type</span>
            <span>Nullable</span>
            <span></span>
          </div>

          <div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
            <NychInputText v-model="col.name" placeholder="column_name" fluid />
            <NychSelect
              v-model="col.col_type"
              :options="COLUMN_TYPES"
              fluid
            />
            <div class="nullable-toggle">
              <input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
              <label :for="`nullable-${i}`">Yes</label>
            </div>
            <button type="button" class="remove-btn" @click="removeColumn(i)"></button>
          </div>

          <div class="empty-columns" v-if="createForm.columns.length === 0">
            <span>Add at least one column.</span>
          </div>
        </div>

        <NychButton type="submit" fluid :disabled="!canSubmitCreate" label="Create Table" />
      </form>
    </NychDialog>

Replace with:

    <!-- Create Table Dialog -->
    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(700px,95vw)]">
        <NychDialogHeader><NychDialogTitle>New Table</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Table Name</label>
            <NychInput v-model="createForm.name" placeholder="my_table" class="w-full" autocomplete="off" />
            <p class="hint">Lowercase letters, numbers, and underscores only.</p>
          </div>

          <div class="columns-section">
            <div class="columns-header">
              <label>Columns</label>
              <NychButton type="button" size="sm" @click="addColumn">+ Add Column</NychButton>
            </div>
            <div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>

            <div class="column-row header-row">
              <span>Name</span>
              <span>Type</span>
              <span>Nullable</span>
              <span></span>
            </div>

            <div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
              <NychInput v-model="col.name" placeholder="column_name" class="w-full" />
              <NychSelect v-model="col.col_type" class="w-full">
                <NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
                <NychSelectContent>
                  <NychSelectItem v-for="ct in COLUMN_TYPES" :key="ct" :value="ct">{{ ct }}</NychSelectItem>
                </NychSelectContent>
              </NychSelect>
              <div class="nullable-toggle">
                <input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
                <label :for="`nullable-${i}`">Yes</label>
              </div>
              <button type="button" class="remove-btn" @click="removeColumn(i)"></button>
            </div>

            <div class="empty-columns" v-if="createForm.columns.length === 0">
              <span>Add at least one column.</span>
            </div>
          </div>

          <NychButton type="submit" class="w-full" :disabled="!canSubmitCreate">Create Table</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the inspect dialog

Find:

    <!-- Inspect Dialog (read-only) -->
    <NychDialog v-model:visible="showInspect" :header="`Inspect — ${inspectPreview?.table_name ?? ''}`" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
      <div v-if="inspectLoading" class="preview-loading">Loading table data…</div>

Replace with:

    <!-- Inspect Dialog (read-only) -->
    <NychDialog v-model:open="showInspect">
      <NychDialogContent class="w-[min(760px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Inspect — {{ inspectPreview?.table_name ?? '' }}</NychDialogTitle></NychDialogHeader>
      <div v-if="inspectLoading" class="preview-loading">Loading table data…</div>

Find (the closing tag for this dialog — it's the one immediately before the <!-- Drop Preview Dialog --> comment):

        <div class="empty-state sample-empty" v-else>
          <span class="empty-icon"></span>
          <span class="empty-label">Empty table</span>
          <span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
        </div>
      </div>
    </NychDialog>

    <!-- Drop Preview Dialog -->

Replace with:

        <div class="empty-state sample-empty" v-else>
          <span class="empty-icon"></span>
          <span class="empty-label">Empty table</span>
          <span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
        </div>
      </div>
      </NychDialogContent>
    </NychDialog>

    <!-- Drop Preview Dialog -->
  • Step 4: Update the drop-preview dialog

Find:

    <!-- Drop Preview Dialog -->
    <NychDialog v-model:visible="showDropPreview" header="Drop Table" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
      <div v-if="dropPreviewLoading" class="preview-loading">Loading table data…</div>

Replace with:

    <!-- Drop Preview Dialog -->
    <NychDialog v-model:open="showDropPreview">
      <NychDialogContent class="w-[min(760px,95vw)]">
        <NychDialogHeader><NychDialogTitle>Drop Table</NychDialogTitle></NychDialogHeader>
      <div v-if="dropPreviewLoading" class="preview-loading">Loading table data…</div>

Find:

        <div class="confirm-section">
          <label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
          <NychInputText v-model="dropConfirmName" :placeholder="dropPreview.table_name" fluid />
        </div>

        <div class="drop-actions">
          <NychButton severity="secondary" @click="showDropPreview = false" label="Cancel" />
          <NychButton severity="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop" label="Drop Table" />
        </div>
      </div>
    </NychDialog>
  </div>
</template>

Replace with:

        <div class="confirm-section">
          <label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
          <NychInput v-model="dropConfirmName" :placeholder="dropPreview.table_name" class="w-full" />
        </div>

        <div class="drop-actions">
          <NychButton variant="secondary" @click="showDropPreview = false">Cancel</NychButton>
          <NychButton variant="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop">Drop Table</NychButton>
        </div>
      </div>
      </NychDialogContent>
    </NychDialog>
  </div>
</template>
  • 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
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 NychMessageNychAlert 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:

      <NychButton @click="showCreate = true" label="+ New Key" />

Replace with:

      <NychButton @click="showCreate = true">+ New Key</NychButton>

Find:

              <NychButton size="small" severity="danger" @click="revoke(k.id, k.name)" label="Revoke" />

Replace with:

              <NychButton size="sm" variant="danger" @click="revoke(k.id, k.name)">Revoke</NychButton>
  • Step 2: Update the create dialog

Find:

    <!-- Create dialog -->
    <NychDialog v-model:visible="showCreate" header="New API Key" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
      <form @submit.prevent="submitCreate" class="dialog-form">
        <div class="field">
          <label>Name</label>
          <NychInputText v-model="form.name" placeholder="e.g. CI pipeline, data importer" fluid />
        </div>
        <div class="field">
          <label>Role</label>
          <NychSelect v-model="form.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" placeholder="Select a role" fluid />
          <p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
        </div>
        <div class="field">
          <label>Expires <span class="optional">(optional — leave blank for no expiry)</span></label>
          <NychInputText v-model="form.expires_at" type="datetime-local" fluid />
        </div>
        <NychButton type="submit" :disabled="!form.name || !form.permissions_mask" fluid label="Generate Key" />
      </form>
    </NychDialog>

Replace with:

    <!-- Create dialog -->
    <NychDialog v-model:open="showCreate">
      <NychDialogContent class="w-[min(560px,95vw)]">
        <NychDialogHeader><NychDialogTitle>New API Key</NychDialogTitle></NychDialogHeader>
        <form @submit.prevent="submitCreate" class="dialog-form">
          <div class="field">
            <label>Name</label>
            <NychInput v-model="form.name" placeholder="e.g. CI pipeline, data importer" class="w-full" />
          </div>
          <div class="field">
            <label>Role</label>
            <NychSelect v-model="form.permissions_mask" class="w-full">
              <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>
            <p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
          </div>
          <div class="field">
            <label>Expires <span class="optional">(optional — leave blank for no expiry)</span></label>
            <NychInput v-model="form.expires_at" type="datetime-local" class="w-full" />
          </div>
          <NychButton type="submit" :disabled="!form.name || !form.permissions_mask" class="w-full">Generate Key</NychButton>
        </form>
      </NychDialogContent>
    </NychDialog>
  • Step 3: Update the key-reveal dialog — NychMessageNychAlert, 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:

    <!-- Key reveal dialog — shown once after creation -->
    <NychDialog v-model:visible="showReveal" header="API Key Created" :modal="true" :draggable="false" :closable="false" style="width: min(600px, 95vw)">
      <div class="reveal-body">
        <NychMessage severity="warn" class="reveal-warning">
          Copy this key now — it will <strong>not</strong> be shown again.
        </NychMessage>
        <div class="key-display">
          <code class="key-text">{{ newKey }}</code>
          <button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
            {{ copied ? '✓' : '⎘' }}
          </button>
        </div>
        <NychButton fluid @click="closeReveal" label="I've saved the key" />
      </div>
    </NychDialog>

Replace with:

    <!-- Key reveal dialog — shown once after creation -->
    <NychDialog :open="showReveal" @update:open="onRevealOpenChange">
      <NychDialogContent class="w-[min(600px,95vw)]" :show-close-button="false">
        <NychDialogHeader><NychDialogTitle>API Key Created</NychDialogTitle></NychDialogHeader>
        <div class="reveal-body">
          <NychAlert variant="warning" class="reveal-warning">
            Copy this key now — it will <strong>not</strong> be shown again.
          </NychAlert>
          <div class="key-display">
            <code class="key-text">{{ newKey }}</code>
            <button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
              {{ copied ? '✓' : '⎘' }}
            </button>
          </div>
          <NychButton class="w-full" @click="closeReveal">I've saved the key</NychButton>
        </div>
      </NychDialogContent>
    </NychDialog>
  • Step 4: Add the onRevealOpenChange guard to the script

Find:

function closeReveal() {
  showReveal.value = false
  newKey.value = ''
}

Replace with:

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
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.