feat(spa): port prototype dashboard + utilities views (Wave 5)

Rewrite Dashboard, Categories, ImportExport, TestRunner, and Settings
1:1 with the React prototype, dropping all PrimeVue from these screens
in favour of the local ui/* primitives.

- Dashboard: hero + 6-stat grid + recent/favorites tabbed list.
- Categories: sticky-header table with inline name edit, color picker
  popover, sort_order field, and module count badge.
- ImportExport: tabbed export/import with collapsible group tree,
  presets row, conflict badges, and bundle summary using ui/Card +
  ui/Checkbox + ui/Select.
- TestRunner: kind segmented control, ui/Select module picker, raw
  histogram bars and constraint matrix table; deterministic microtask
  yield in run() instead of setTimeout.
- Settings: real About / Theme / Storage cards wired to uiStore.

Tests + new Settings.test.ts; updated selectors for the dropped
PrimeVue primitives. All 338 vitest tests pass; 0 PrimeVue refs left
in these views; net bundle (gzip) -24 kB across the five screens.
This commit is contained in:
DumiFlex
2026-04-27 19:43:07 +03:00
parent 84fc961fc2
commit 1d13048982
9 changed files with 1257 additions and 1568 deletions
+16
View File
@@ -90,7 +90,23 @@ describe("Dashboard.vue", () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const wrap = mountView();
await flushPromises();
// Default tab is Recent — empty-state copy.
expect(wrap.text()).toContain("No edits yet.");
// Switch to Favorites tab and confirm its empty-state copy.
await wrap.get('[data-test="dashboard-tab-favorites"]').trigger("click");
expect(wrap.text()).toContain("No favorites yet.");
});
it("clicking a stat card navigates to that kind list", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const router = makeRouter();
const push = vi.spyOn(router, "push");
const wrap = mount(Dashboard, {
global: { plugins: [router, PrimeVue, ToastService, ConfirmationService] },
});
await flushPromises();
const stats = wrap.findAll(".dashboard__stat");
await stats[0]!.trigger("click"); // wildcards
expect(push).toHaveBeenCalledWith("/wildcards");
});
});
+2 -3
View File
@@ -218,10 +218,9 @@ describe("ImportExport.vue — Import tab", () => {
await wrap.find('[data-test="io-export-select-none"]').trigger("click");
await flushPromises();
// Find the group's checkbox (PrimeVue Checkbox uses an inner <input>).
// ui/Checkbox is a custom <button role="checkbox"> — click it to toggle on.
const groupCheckEl = wrap.get('[data-test="io-export-group-check-wildcard"]');
const input = groupCheckEl.find("input");
await input.setValue(true);
await groupCheckEl.trigger("click");
await flushPromises();
// Both rows should now be selected → download should be enabled.
+45
View File
@@ -0,0 +1,45 @@
import { mount } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Settings from "../views/Settings.vue";
import { useUiStore } from "../stores/uiStore";
beforeEach(() => {
setActivePinia(createPinia());
});
afterEach(() => vi.clearAllMocks());
describe("Settings.vue", () => {
it("renders About / Theme / Storage cards", () => {
const wrap = mount(Settings);
const text = wrap.text();
expect(text).toContain("Settings");
expect(text).toContain("About");
expect(text).toContain("Theme");
expect(text).toContain("Storage");
});
it("clicking a theme chip swaps uiStore.themeMode", async () => {
const wrap = mount(Settings);
const store = useUiStore();
expect(store.themeMode).toBeDefined();
await wrap.get('[data-test="settings-theme-light"]').trigger("click");
expect(store.themeMode).toBe("light");
await wrap.get('[data-test="settings-theme-auto"]').trigger("click");
expect(store.themeMode).toBe("auto");
await wrap.get('[data-test="settings-theme-dark"]').trigger("click");
expect(store.themeMode).toBe("dark");
});
it("active theme chip carries data-active='true'", async () => {
const wrap = mount(Settings);
const store = useUiStore();
store.setThemeMode("light");
await wrap.vm.$nextTick();
const lightChip = wrap.get('[data-test="settings-theme-light"]');
const darkChip = wrap.get('[data-test="settings-theme-dark"]');
expect(lightChip.attributes("data-active")).toBe("true");
expect(darkChip.attributes("data-active")).toBe("false");
});
});
+18 -11
View File
@@ -133,22 +133,24 @@ describe("TestRunner.vue", () => {
it("kind selector switches the module dropdown to that kind only", async () => {
const wrap = mountRunner();
await flushPromises();
// Locate the SelectButton DOM and click the Combine option.
// Click the Combine button in the segmented kind picker.
const kindSelector = wrap.find('[data-test="kind-selector"]');
const combineOpt = kindSelector
.findAll("div, span, button")
.find((el) => el.text().trim() === "Combine");
.findAll("button")
.find((el) => el.text().trim().includes("Combine"));
expect(combineOpt).toBeTruthy();
await combineOpt!.trigger("click");
await flushPromises();
const select = wrap.find('[data-test="module-select"]');
await select.trigger("click");
// ui/Select renders the options inline once open. Click the trigger button.
const trigger = wrap
.find('[data-test="module-select"]')
.find('[data-test="select-trigger"]');
await trigger.trigger("click");
await flushPromises();
// The PrimeVue overlay panel renders the option list. Combine modules
// should appear; non-combine ones should not.
const overlayText = document.body.textContent ?? "";
expect(overlayText).toContain("Subject Phrase");
expect(overlayText).not.toContain("Hair Color");
// Combine modules should appear in the menu; non-combine ones should not.
const html = wrap.html();
expect(html).toContain("Subject Phrase");
expect(html).not.toContain("Hair Color");
});
it("runs wildcard kind and renders histogram panel", async () => {
@@ -158,6 +160,7 @@ describe("TestRunner.vue", () => {
expect(runBtn).toBeTruthy();
await runBtn!.trigger("click");
await flushPromises();
await flushPromises();
expect(wrap.find('[data-test="result-wildcard"]').exists()).toBe(true);
});
@@ -173,6 +176,7 @@ describe("TestRunner.vue", () => {
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
await flushPromises();
expect(wrap.find('[data-test="result-fixed"]').exists()).toBe(true);
expect(wrap.text()).toContain("$name");
expect(wrap.text()).toContain("Mira");
@@ -183,13 +187,14 @@ describe("TestRunner.vue", () => {
await flushPromises();
const kindSelector = wrap.find('[data-test="kind-selector"]');
const plOpt = kindSelector
.findAll("div, span, button")
.findAll("button")
.find((el) => el.text().trim() === "Pipeline");
await plOpt!.trigger("click");
await flushPromises();
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
await flushPromises();
expect(wrap.find('[data-test="result-pipeline"]').exists()).toBe(true);
});
@@ -205,6 +210,7 @@ describe("TestRunner.vue", () => {
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
await flushPromises();
expect(wrap.find('[data-test="result-derivation"]').exists()).toBe(true);
});
@@ -220,6 +226,7 @@ describe("TestRunner.vue", () => {
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
await runBtn!.trigger("click");
await flushPromises();
await flushPromises();
expect(wrap.find('[data-test="result-constraint"]').exists()).toBe(true);
});
});
+128 -262
View File
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import Card from "primevue/card";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Tag from "primevue/tag";
import Dialog from "primevue/dialog";
import { useToast } from "primevue/usetoast";
import Button from "../components/ui/Button.vue";
import Card from "../components/ui/Card.vue";
import Field from "../components/ui/Field.vue";
import Icon from "../components/ui/Icon.vue";
import Input from "../components/ui/Input.vue";
import ColorPicker from "../components/ColorPicker.vue";
import { useCategoryStore } from "../stores/categoryStore";
import { useModuleStore } from "../stores/moduleStore";
import { ApiError } from "../api/client";
import { useToast } from "../composables/useToast";
import type { CategoryRow } from "../api/types";
import ColorPicker from "../components/ColorPicker.vue";
import RelativeDate from "../components/RelativeDate.vue";
const store = useCategoryStore();
const moduleStore = useModuleStore();
@@ -24,12 +23,6 @@ const newColor = ref("#a78bfa");
const editingId = ref<string | null>(null);
const editingName = ref("");
// Edit-modal state for the pencil action.
const modalOpen = ref(false);
const modalRow = ref<CategoryRow | null>(null);
const modalName = ref("");
const modalColor = ref("#a78bfa");
// Module counts keyed by category id, derived from the loaded module list.
const countsByCategory = computed(() => {
const map = new Map<string, number>();
@@ -49,15 +42,9 @@ function moduleCount(row: CategoryRow): number {
return countsByCategory.value.get(row.id) ?? 0;
}
// CategoryRow doesn't formally include updated_at, but the backend may
// include it; expose a helper that reads it defensively for the column.
function rowUpdatedAt(row: CategoryRow): string | undefined {
return (row as CategoryRow & { updated_at?: string }).updated_at;
}
function reportError(e: unknown, summary: string) {
const msg = e instanceof ApiError ? e.message : String(e);
toast.add({ severity: "error", summary, detail: msg, life: 4000 });
toast.push({ severity: "error", summary, detail: msg, life: 4000 });
}
async function add() {
@@ -67,7 +54,7 @@ async function add() {
await store.create({ name, color: newColor.value });
newName.value = "";
newColor.value = "#a78bfa";
toast.add({ severity: "success", summary: "Created", life: 2000 });
toast.push({ severity: "success", summary: "Created", life: 2000 });
} catch (e) {
reportError(e, "Failed");
}
@@ -76,7 +63,7 @@ async function add() {
async function remove(row: CategoryRow) {
try {
await store.remove(row.id);
toast.add({ severity: "success", summary: "Deleted", life: 2000 });
toast.push({ severity: "success", summary: "Deleted", life: 2000 });
} catch (e) {
reportError(e, "Delete failed");
}
@@ -91,6 +78,15 @@ async function changeColor(row: CategoryRow, value: string) {
}
}
async function changeSortOrder(row: CategoryRow, value: number) {
if (value === row.sort_order) return;
try {
await store.update(row.id, { sort_order: value });
} catch (e) {
reportError(e, "Update failed");
}
}
function startInlineEdit(row: CategoryRow) {
editingId.value = row.id;
editingName.value = row.name;
@@ -117,106 +113,67 @@ async function commitInlineEdit(row: CategoryRow) {
}
try {
await store.update(row.id, { name });
toast.add({ severity: "success", summary: "Renamed", life: 1500 });
toast.push({ severity: "success", summary: "Renamed", life: 1500 });
} catch (e) {
reportError(e, "Rename failed");
} finally {
cancelInlineEdit();
}
}
function openEditModal(row: CategoryRow) {
modalRow.value = row;
modalName.value = row.name;
modalColor.value = row.color ?? "#a78bfa";
modalOpen.value = true;
}
function closeEditModal() {
modalOpen.value = false;
modalRow.value = null;
}
async function saveEditModal() {
if (!modalRow.value) return;
const id = modalRow.value.id;
const name = modalName.value.trim();
if (!name) {
toast.add({ severity: "warn", summary: "Name is required", life: 2000 });
return;
}
try {
await store.update(id, { name, color: modalColor.value });
toast.add({ severity: "success", summary: "Saved", life: 1500 });
closeEditModal();
} catch (e) {
reportError(e, "Save failed");
}
}
</script>
<template>
<div class="categories-page">
<header class="categories-page__header">
<h1 class="text-xl font-semibold m-0 text-wp-text">Categories</h1>
<p class="text-sm text-wp-text2 m-0 mt-1">
Group modules into categories. Each category has a name and a color used as a chip in lists.
</p>
</header>
<div class="wp-page wp-page--fill wp-cat-page">
<div class="wp-page__header">
<div class="wp-page__title-wrap">
<h1 class="wp-page__title">Categories</h1>
<p class="wp-page__subtitle">
Tag groups for filtering modules. Color appears on category chips in the DataTable.
</p>
</div>
</div>
<Card class="categories-page__new">
<template #title>
<span class="text-sm font-medium">New category</span>
</template>
<template #content>
<div class="categories-new-row">
<InputText
id="cat-name"
<Card title="New category">
<div class="wp-cat-newrow">
<Field label="Name" class="wp-cat-newrow__name">
<Input
v-model="newName"
placeholder="Style"
class="flex-1"
aria-label="New category name"
data-test="new-cat-name"
@keydown.enter="add"
/>
<ColorPicker
v-model="newColor"
aria-label="New category color"
/>
<Button
label="Add"
icon="pi pi-plus"
severity="primary"
data-test="add-category-btn"
@click="add"
/>
</div>
</template>
</Field>
<Field label="Color">
<ColorPicker v-model="newColor" aria-label="New category color" />
</Field>
<Button
variant="primary"
icon="pi-plus"
data-test="add-category-btn"
@click="add"
>Add</Button>
</div>
</Card>
<div class="categories-page__table-wrap">
<table class="categories-table">
<div class="wp-table-wrap wp-table-wrap--scroll">
<table class="wp-table wp-table--sticky-head wp-cat-table">
<thead>
<tr>
<th class="categories-table__col-color">
Color
</th>
<th>Name</th>
<th class="categories-table__col-count">
Modules
</th>
<th class="categories-table__col-updated">
Updated
</th>
<th class="categories-table__col-actions">
Actions
</th>
<th class="wp-cat-col--color">Color</th>
<th class="wp-cat-col--sort">Sort</th>
<th class="wp-cat-col--count">Modules</th>
<th class="wp-cat-col--actions">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="store.items.length === 0">
<td colspan="5" class="categories-table__empty">
No categories yet.
<td colspan="5">
<div class="wp-empty">
<div class="wp-empty__icon"><Icon name="pi-bookmark" /></div>
<div class="wp-dim">No categories yet.</div>
</div>
</td>
</tr>
<tr
@@ -225,20 +182,12 @@ async function saveEditModal() {
class="wp-cat-row"
:data-test="`cat-row-${row.id}`"
>
<td>
<ColorPicker
:model-value="row.color || '#a78bfa'"
:aria-label="`Edit color for ${row.name}`"
@update:model-value="(v) => changeColor(row, v)"
/>
</td>
<td>
<template v-if="editingId === row.id">
<InputText
<Input
v-model="editingName"
:data-test="`cat-name-input-${row.id}`"
aria-label="Edit category name"
class="w-full"
@keydown.enter="commitInlineEdit(row)"
@keydown.esc="cancelInlineEdit"
@blur="commitInlineEdit(row)"
@@ -246,209 +195,126 @@ async function saveEditModal() {
</template>
<template v-else>
<span
class="categories-table__name"
class="wp-cat-name"
:data-test="`cat-name-${row.id}`"
tabindex="0"
role="button"
:aria-label="`Edit ${row.name}`"
@dblclick="startInlineEdit(row)"
:style="{ background: row.color || 'var(--wp-bg-3)' }"
@click="startInlineEdit(row)"
@keydown.enter.prevent="startInlineEdit(row)"
>
<Tag
:value="row.name"
:style="{ background: row.color || 'var(--wp-bg-3)', color: '#fff' }"
/>
</span>
>{{ row.name }}</span>
</template>
</td>
<td>
<span class="categories-table__count" :data-test="`cat-count-${row.id}`">
<div class="wp-cat-color">
<ColorPicker
:model-value="row.color || '#a78bfa'"
:aria-label="`Edit color for ${row.name}`"
@update:model-value="(v) => changeColor(row, v)"
/>
<span class="wp-mono wp-dim wp-cat-color__hex">{{ row.color || '—' }}</span>
</div>
</td>
<td>
<Input
type="number"
size="sm"
:model-value="row.sort_order"
aria-label="Sort order"
:data-test="`cat-sort-${row.id}`"
@blur="(e) => changeSortOrder(row, Number((e.target as HTMLInputElement).value) || 0)"
/>
</td>
<td>
<span class="wp-cat-count" :data-test="`cat-count-${row.id}`">
{{ moduleCount(row) }}
</span>
</td>
<td class="text-wp-text2 text-xs">
<RelativeDate
v-if="rowUpdatedAt(row)"
:value="rowUpdatedAt(row)!"
<td class="wp-cat-col--actions">
<Button
variant="ghost"
size="sm"
icon="pi-trash"
:aria-label="`Delete ${row.name}`"
:data-test="`cat-delete-${row.id}`"
@click="remove(row)"
/>
<span v-else>—</span>
</td>
<td>
<div class="categories-table__actions">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
:aria-label="`Edit ${row.name}`"
:data-test="`cat-edit-${row.id}`"
@click="openEditModal(row)"
/>
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
:aria-label="`Delete ${row.name}`"
:data-test="`cat-delete-${row.id}`"
@click="remove(row)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Dialog
v-model:visible="modalOpen"
modal
header="Edit category"
:style="{ width: '24rem' }"
:draggable="false"
@hide="closeEditModal"
>
<div class="flex flex-col gap-3">
<div>
<label class="block text-xs text-wp-text2 mb-1" for="modal-cat-name">Name</label>
<InputText
id="modal-cat-name"
v-model="modalName"
class="w-full"
data-test="modal-cat-name"
@keydown.enter="saveEditModal"
/>
</div>
<div>
<label class="block text-xs text-wp-text2 mb-1">Color</label>
<ColorPicker v-model="modalColor" aria-label="Edit category color" />
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="closeEditModal" />
<Button
label="Save"
icon="pi pi-check"
severity="primary"
data-test="modal-save"
@click="saveEditModal"
/>
</template>
</Dialog>
</div>
</template>
<style scoped>
.categories-page {
.wp-cat-page { padding: 18px 22px 40px; max-width: 1200px; margin: 0 auto; }
.wp-cat-newrow {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 1.25rem;
gap: 1rem;
color: var(--wp-text);
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.wp-cat-newrow__name { flex: 1; min-width: 200px; }
.categories-page__header {
flex: 0 0 auto;
}
.categories-page__new {
flex: 0 0 auto;
}
.categories-page__new :deep(.p-card-body) {
padding: 0.75rem 1rem;
}
.categories-page__new :deep(.p-card-content) {
padding: 0;
}
.categories-new-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.categories-new-row > .flex-1 { flex: 1 1 auto; }
/* Sticky-header scroll wrap. The container claims the remaining space so
* the header + new-category card stay anchored when categories grow.
*/
.categories-page__table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
.wp-table-wrap--scroll {
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
background: var(--wp-bg-1);
}
.categories-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.categories-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--wp-bg-2);
border-bottom: 1px solid var(--wp-border-strong);
.wp-cat-table thead th {
text-align: left;
font-weight: 500;
color: var(--wp-text-muted);
padding: 0.5rem 0.75rem;
font-size: 12px;
padding: 9px 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--wp-border-strong);
}
.categories-table tbody td {
padding: 0.5rem 0.75rem;
.wp-cat-table tbody td {
padding: 8px 12px;
border-bottom: 1px solid var(--wp-border);
vertical-align: middle;
font-size: 13px;
}
.categories-table tbody tr:last-child td {
border-bottom: none;
}
.wp-cat-table tbody tr:last-child td { border-bottom: none; }
.categories-table__col-color { width: 80px; }
.categories-table__col-count { width: 6rem; }
.categories-table__col-updated { width: 8rem; }
.categories-table__col-actions { width: 7rem; text-align: right; }
.wp-cat-col--color { width: 240px; }
.wp-cat-col--sort { width: 90px; }
.wp-cat-col--count { width: 90px; }
.wp-cat-col--actions { width: 80px; text-align: right; }
.categories-table__col-actions { text-align: right; }
.categories-table__actions {
.wp-cat-name {
display: inline-flex;
gap: 0.25rem;
justify-content: flex-end;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
color: #fff;
font-size: 12px;
font-weight: 500;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
cursor: pointer;
outline: none;
}
.wp-cat-name:focus-visible {
box-shadow: 0 0 0 2px var(--wp-bg-1), 0 0 0 4px var(--wp-accent-500);
}
.categories-table__count {
.wp-cat-color { display: inline-flex; align-items: center; gap: 8px; }
.wp-cat-color__hex { font-size: 11.5px; }
.wp-cat-count {
display: inline-block;
min-width: 2rem;
padding: 0 0.5rem;
padding: 0 8px;
text-align: center;
background: var(--wp-bg-3);
border-radius: 999px;
font-size: 12px;
color: var(--wp-text-muted);
}
.categories-table__empty {
text-align: center;
padding: 2rem 1rem;
color: var(--wp-text-dim);
font-style: italic;
}
.categories-table__name {
cursor: text;
display: inline-flex;
align-items: center;
outline: none;
}
.categories-table__name:focus-visible {
box-shadow: 0 0 0 2px var(--wp-accent-500);
border-radius: 4px;
}
</style>
+164 -399
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import Menu from "primevue/menu";
import Button from "../components/ui/Button.vue";
import Card from "../components/ui/Card.vue";
import Icon from "../components/ui/Icon.vue";
import RelativeDate from "../components/RelativeDate.vue";
import { api } from "../api/client";
import type { ModuleRow, ModuleType } from "../api/types";
import { useCategoryStore } from "../stores/categoryStore";
import RelativeDate from "../components/RelativeDate.vue";
interface KindMeta {
type: ModuleType;
@@ -18,12 +19,12 @@ interface KindMeta {
// Mirrors KIND_META from docs/design-handoff/wildcardpipeline/project/data.jsx.
const KIND_META: KindMeta[] = [
{ type: "wildcard", label: "Wildcards", icon: "pi pi-th-large", color: "var(--wp-kind-wildcard)", slug: "wildcards" },
{ type: "fixed_values", label: "Fixed Values", icon: "pi pi-tag", color: "var(--wp-kind-fixed)", slug: "fixed-values" },
{ type: "combine", label: "Combines", icon: "pi pi-share-alt", color: "var(--wp-kind-combine)", slug: "combines" },
{ type: "derivation", label: "Derivations", icon: "pi pi-code", color: "var(--wp-kind-derivation)", slug: "derivations" },
{ type: "constraint", label: "Constraints", icon: "pi pi-sitemap", color: "var(--wp-kind-constraint)", slug: "constraints" },
{ type: "pipeline", label: "Pipelines", icon: "pi pi-list", color: "var(--wp-kind-pipeline)", slug: "pipelines" },
{ type: "wildcard", label: "Wildcards", icon: "pi-th-large", color: "var(--wp-kind-wildcard)", slug: "wildcards" },
{ type: "fixed_values", label: "Fixed Values", icon: "pi-tag", color: "var(--wp-kind-fixed)", slug: "fixed-values" },
{ type: "combine", label: "Combines", icon: "pi-share-alt", color: "var(--wp-kind-combine)", slug: "combines" },
{ type: "derivation", label: "Derivations", icon: "pi-code", color: "var(--wp-kind-derivation)", slug: "derivations" },
{ type: "constraint", label: "Constraints", icon: "pi-sitemap", color: "var(--wp-kind-constraint)", slug: "constraints" },
{ type: "pipeline", label: "Pipelines", icon: "pi-list", color: "var(--wp-kind-pipeline)", slug: "pipelines" },
];
const KIND_BY_TYPE: Record<ModuleType, KindMeta> = KIND_META.reduce((acc, k) => {
@@ -45,24 +46,11 @@ const counts = ref<Record<ModuleType, number>>({
const recentItems = ref<ModuleRow[]>([]);
const favoriteItems = ref<ModuleRow[]>([]);
const tab = ref<"recent" | "favorites">("recent");
const logoUrl = `${import.meta.env.BASE_URL}images/favicon.svg`;
const wikiUrl = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline/wiki";
const newModuleMenu = ref<InstanceType<typeof Menu> | null>(null);
const newModuleItems = computed(() =>
KIND_META.map((k) => ({
label: k.label,
icon: k.icon,
// TODO: route to the kind-specific create form when slug-aware deeplinks land.
command: () => router.push(`/${k.slug}/new`),
})),
);
function toggleNewModuleMenu(event: Event) {
newModuleMenu.value?.toggle(event);
}
function openDocs() {
window.open(wikiUrl, "_blank", "noopener");
}
@@ -71,6 +59,22 @@ function navigateToKind(slug: string) {
router.push(`/${slug}`);
}
function openTestRunner() {
router.push("/test");
}
function openImportExport() {
router.push("/import-export");
}
function newWildcard() {
router.push("/wildcards/new");
}
function newPipeline() {
router.push("/pipelines/new");
}
function editRow(row: ModuleRow) {
const meta = KIND_BY_TYPE[row.type];
if (!meta) return;
@@ -88,6 +92,10 @@ function categoryFor(row: ModuleRow): { name: string; color: string | null } | u
return categoryById.value.get(row.category_id);
}
const visibleItems = computed<ModuleRow[]>(() =>
tab.value === "favorites" ? favoriteItems.value : recentItems.value,
);
async function loadCounts() {
// Fetch one record per kind in parallel; ModuleListResponse exposes total.
await Promise.all(
@@ -103,7 +111,6 @@ async function loadCounts() {
}
async function loadRecent() {
// TODO: add server-side sort=updated_at&order=desc — for now we client-slice.
try {
const res = await api.modules.list({ limit: 50 });
const sorted = [...res.items].sort((a, b) => {
@@ -137,384 +144,159 @@ onMounted(async () => {
</script>
<template>
<div class="dashboard">
<!-- A) Brand hero -->
<section class="dashboard__hero">
<div class="dashboard__hero-icon">
<img :src="logoUrl" alt="" />
</div>
<div class="wp-page">
<!-- Hero -->
<div class="wp-hero">
<div class="wp-hero__icon"><img :src="logoUrl" alt="" /></div>
<div class="dashboard__hero-text">
<h2 class="dashboard__hero-title">Welcome to Wildcard Pipeline</h2>
<p class="dashboard__hero-sub">
Modular procedural prompt generation. Build, test, and reuse wildcards.
<h2 class="wp-hero__title">Welcome to Wildcard Pipeline</h2>
<p class="wp-hero__sub">
Manage modules — wildcards, fixed values, combines, derivations and constraints — that drop into ComfyUI prompts as snapshots.
</p>
</div>
<div class="dashboard__hero-actions">
<Button
label="Browse docs"
icon="pi pi-book"
severity="secondary"
outlined
class="dashboard__hero-btn dashboard__hero-btn--outline"
@click="openDocs"
/>
<Button
label="New module"
icon="pi pi-plus"
class="dashboard__hero-btn dashboard__hero-btn--primary"
aria-haspopup="true"
aria-controls="dashboard-new-module-menu"
@click="toggleNewModuleMenu"
/>
<Menu
id="dashboard-new-module-menu"
ref="newModuleMenu"
:model="newModuleItems"
:popup="true"
/>
<div class="wp-hsplit dashboard__hero-actions">
<Button variant="outline" icon="pi-book" @click="openDocs">Docs</Button>
<Button variant="primary" icon="pi-plus" @click="newWildcard">New module</Button>
</div>
</section>
</div>
<!-- B) Stat cards -->
<section class="dashboard__stats" aria-label="Module counts">
<!-- Stats -->
<div class="wp-stats" aria-label="Module counts">
<button
v-for="kind in KIND_META"
:key="kind.type"
type="button"
class="dashboard__stat"
class="wp-stat dashboard__stat"
:aria-label="`View all ${kind.label}`"
@click="navigateToKind(kind.slug)"
@keydown.enter.prevent="navigateToKind(kind.slug)"
@keydown.space.prevent="navigateToKind(kind.slug)"
>
<span class="dashboard__stat-icon" :style="{ color: kind.color, background: `color-mix(in oklab, ${kind.color} 14%, transparent)` }">
<i :class="kind.icon" aria-hidden="true" />
</span>
<span class="dashboard__stat-label">{{ kind.label }}</span>
<span class="dashboard__stat-value">{{ counts[kind.type] }}</span>
<span class="dashboard__stat-link">
View all
<i class="pi pi-arrow-right" aria-hidden="true" />
</span>
<div
class="wp-stat__icon"
:style="{ color: kind.color, background: `color-mix(in oklab, ${kind.color} 16%, transparent)` }"
>
<Icon :name="kind.icon" />
</div>
<div class="wp-stat__label">{{ kind.label }}</div>
<div class="wp-stat__value">{{ counts[kind.type] }}</div>
<div class="wp-stat__delta">
<Icon name="pi-arrow-up-right" :size="9" /> View all
</div>
</button>
</section>
</div>
<!-- C) Quick actions -->
<section class="dashboard__quick" aria-label="Quick actions">
<Button
label="Test Runner"
icon="pi pi-bolt"
severity="secondary"
outlined
class="dashboard__quick-btn"
@click="router.push('/test')"
/>
<Button
label="Categories"
icon="pi pi-folder"
severity="secondary"
outlined
class="dashboard__quick-btn"
@click="router.push('/categories')"
/>
<Button
label="Import / Export"
icon="pi pi-arrow-right-arrow-left"
severity="secondary"
outlined
class="dashboard__quick-btn"
@click="router.push('/import-export')"
/>
</section>
<!-- Quick actions row -->
<div class="dashboard__quick">
<Button variant="ghost" icon="pi-bolt" @click="openTestRunner">Open test runner</Button>
<Button variant="ghost" icon="pi-arrow-right-arrow-left" @click="openImportExport">Import / Export</Button>
<Button variant="ghost" icon="pi-plus" @click="newPipeline">New pipeline</Button>
</div>
<!-- D) Recent + Favorites -->
<section class="dashboard__recent">
<article class="dashboard__panel">
<header class="dashboard__panel-header">
<h3 class="dashboard__panel-title">Recently updated</h3>
<a class="dashboard__panel-link" href="#" @click.prevent="router.push('/wildcards')">
View all
<i class="pi pi-arrow-right" aria-hidden="true" />
</a>
</header>
<ul v-if="recentItems.length" class="dashboard__list">
<li
v-for="row in recentItems"
:key="row.id"
class="dashboard__row"
tabindex="0"
role="button"
:aria-label="`Edit ${row.name}`"
@click="editRow(row)"
@keydown.enter.prevent="editRow(row)"
@keydown.space.prevent="editRow(row)"
<!-- Recent / Favorites -->
<Card padding>
<template #actions>
<div class="wp-tabs wp-recent__tabs">
<button
type="button"
class="wp-tabs__btn wp-tab"
:data-active="tab === 'recent' ? 'true' : 'false'"
data-test="dashboard-tab-recent"
@click="tab = 'recent'"
>
<span
class="dashboard__row-icon"
:style="{
color: KIND_BY_TYPE[row.type]?.color,
background: `color-mix(in oklab, ${KIND_BY_TYPE[row.type]?.color} 18%, transparent)`,
}"
>
<i :class="KIND_BY_TYPE[row.type]?.icon ?? 'pi pi-circle'" aria-hidden="true" />
</span>
<span class="dashboard__row-name">{{ row.name }}</span>
<span
v-if="categoryFor(row)"
class="dashboard__row-chip"
:style="{ background: categoryFor(row)!.color || 'var(--wp-bg-3)' }"
>{{ categoryFor(row)!.name }}</span>
<RelativeDate :value="row.updated_at" />
</li>
</ul>
<p v-else class="dashboard__empty">No edits yet.</p>
</article>
<article class="dashboard__panel">
<header class="dashboard__panel-header">
<h3 class="dashboard__panel-title">Favorites</h3>
<a class="dashboard__panel-link" href="#" @click.prevent="router.push('/wildcards')">
View all
<i class="pi pi-arrow-right" aria-hidden="true" />
</a>
</header>
<ul v-if="favoriteItems.length" class="dashboard__list">
<li
v-for="row in favoriteItems"
:key="row.id"
class="dashboard__row"
tabindex="0"
role="button"
:aria-label="`Edit ${row.name}`"
@click="editRow(row)"
@keydown.enter.prevent="editRow(row)"
@keydown.space.prevent="editRow(row)"
<Icon name="pi-clock" /> Recent edits
</button>
<button
type="button"
class="wp-tabs__btn wp-tab"
:data-active="tab === 'favorites' ? 'true' : 'false'"
data-test="dashboard-tab-favorites"
@click="tab = 'favorites'"
>
<span
class="dashboard__row-icon"
:style="{
color: KIND_BY_TYPE[row.type]?.color,
background: `color-mix(in oklab, ${KIND_BY_TYPE[row.type]?.color} 18%, transparent)`,
}"
>
<i :class="KIND_BY_TYPE[row.type]?.icon ?? 'pi pi-circle'" aria-hidden="true" />
</span>
<span class="dashboard__row-name">{{ row.name }}</span>
<span
v-if="categoryFor(row)"
class="dashboard__row-chip"
:style="{ background: categoryFor(row)!.color || 'var(--wp-bg-3)' }"
>{{ categoryFor(row)!.name }}</span>
<RelativeDate :value="row.updated_at" />
</li>
</ul>
<p v-else class="dashboard__empty">No favorites yet.</p>
</article>
</section>
<Icon name="pi-star-fill" /> Favorites
</button>
<a
class="dashboard__view-all"
href="#"
@click.prevent="navigateToKind('wildcards')"
>View all <Icon name="pi-arrow-right" :size="10" /></a>
</div>
</template>
<div v-if="visibleItems.length" class="wp-list wp-recent">
<div
v-for="row in visibleItems"
:key="row.id"
class="wp-list__row"
tabindex="0"
role="button"
:aria-label="`Edit ${row.name}`"
@click="editRow(row)"
@keydown.enter.prevent="editRow(row)"
@keydown.space.prevent="editRow(row)"
>
<span
class="dashboard__row-icon"
:style="{
color: KIND_BY_TYPE[row.type]?.color,
background: `color-mix(in oklab, ${KIND_BY_TYPE[row.type]?.color} 18%, transparent)`,
}"
>
<Icon :name="KIND_BY_TYPE[row.type]?.icon ?? 'pi-circle'" />
</span>
<span class="dashboard__row-name">{{ row.name }}</span>
<span
v-if="categoryFor(row)"
class="dashboard__row-chip"
:style="{ background: categoryFor(row)!.color || 'var(--wp-bg-3)' }"
>{{ categoryFor(row)!.name }}</span>
<span class="wp-id">{{ row.id }}</span>
<RelativeDate :value="row.updated_at" />
</div>
</div>
<p v-else class="dashboard__empty">
{{ tab === 'favorites' ? 'No favorites yet.' : 'No edits yet.' }}
</p>
</Card>
</div>
</template>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
color: var(--wp-text);
}
.dashboard__hero-text { flex: 1; min-width: 0; }
.dashboard__hero-actions { gap: 8px; }
/* ---------- Hero ---------- */
.dashboard__hero {
position: relative;
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
border-radius: var(--wp-radius-xl);
background:
radial-gradient(120% 140% at 0% 0%, color-mix(in oklab, var(--wp-brand-1) 55%, transparent) 0%, transparent 55%),
radial-gradient(120% 140% at 100% 100%, color-mix(in oklab, var(--wp-brand-3) 50%, transparent) 0%, transparent 60%),
linear-gradient(135deg, var(--wp-brand-1), var(--wp-brand-2), var(--wp-brand-3));
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
color: #fff;
}
.dashboard__hero::after {
content: "";
position: absolute; inset: 0;
background: radial-gradient(80% 100% at 50% 0%, rgba(255, 255, 255, 0.06), transparent 70%);
pointer-events: none;
}
.dashboard__hero-icon {
width: 52px; height: 52px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.12);
display: grid; place-items: center;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.dashboard__hero-icon img { width: 44px; height: 44px; display: block; }
.dashboard__hero-text {
flex: 1;
min-width: 0;
position: relative;
z-index: 1;
}
.dashboard__hero-title {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 2px;
color: #fff;
}
.dashboard__hero-sub {
font-size: 12.5px;
margin: 0;
max-width: 640px;
color: rgba(255, 255, 255, 0.82);
}
.dashboard__hero-actions {
display: flex;
align-items: center;
gap: 8px;
position: relative;
z-index: 1;
}
/* Force translucent treatment regardless of light/dark theme — gradient is dark in both. */
.dashboard__hero :deep(.dashboard__hero-btn.p-button) {
border-radius: var(--wp-radius);
}
.dashboard__hero :deep(.dashboard__hero-btn--outline.p-button) {
background: rgba(255, 255, 255, 0.10);
border-color: rgba(255, 255, 255, 0.18);
color: #fff;
}
.dashboard__hero :deep(.dashboard__hero-btn--outline.p-button:hover) {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.28);
color: #fff;
}
.dashboard__hero :deep(.dashboard__hero-btn--primary.p-button) {
background: rgba(255, 255, 255, 0.94);
border-color: transparent;
color: var(--wp-accent-800);
}
.dashboard__hero :deep(.dashboard__hero-btn--primary.p-button:hover) {
background: #fff;
color: var(--wp-accent-900);
}
/* ---------- Stat cards ---------- */
.dashboard__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
@media (max-width: 1180px) {
.dashboard__stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.dashboard__stats { grid-template-columns: 1fr; }
}
.dashboard__stat {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
transition: border-color 0.15s, transform 0.15s;
}
.dashboard__stat:hover { border-color: var(--wp-border-strong); }
.dashboard__stat:focus-visible {
outline: 2px solid var(--wp-border-focus);
outline-offset: 2px;
}
.dashboard__stat-icon {
position: absolute; top: 10px; right: 10px;
width: 26px; height: 26px;
border-radius: 7px;
display: grid; place-items: center;
font-size: 13px;
}
.dashboard__stat-label {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--wp-text-dim);
font-weight: 600;
}
.dashboard__stat-value {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.dashboard__stat-link {
font-size: 11.5px;
color: var(--wp-accent-text);
display: inline-flex;
align-items: center;
gap: 4px;
}
.dashboard__stat-link i { font-size: 10px; }
/* ---------- Quick actions ---------- */
.dashboard__quick {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dashboard__quick :deep(.dashboard__quick-btn.p-button) {
border-radius: 999px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
color: var(--wp-text);
}
.dashboard__quick :deep(.dashboard__quick-btn.p-button:hover) {
background: var(--wp-bg-3);
border-color: var(--wp-border-strong);
color: var(--wp-text);
}
/* ---------- Recent + Favorites ---------- */
.dashboard__recent {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
}
@media (max-width: 880px) {
.dashboard__recent { grid-template-columns: 1fr; }
}
.dashboard__panel {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
padding: 12px;
.wp-recent__tabs {
display: flex;
flex-direction: column;
gap: 8px;
}
.dashboard__panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
border-bottom: none;
}
.dashboard__panel-title {
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
.wp-tabs__btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: none;
border: 1px solid transparent;
color: var(--wp-text-muted);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
}
.wp-tabs__btn:hover { color: var(--wp-text); }
.wp-tabs__btn[data-active="true"] {
color: var(--wp-text);
background: var(--wp-bg-2);
border-color: var(--wp-border);
}
.dashboard__panel-link {
.dashboard__view-all {
margin-left: auto;
font-size: 12px;
color: var(--wp-accent-text);
text-decoration: none;
@@ -522,38 +304,14 @@ onMounted(async () => {
align-items: center;
gap: 4px;
}
.dashboard__panel-link:hover { color: var(--wp-accent-text-strong); }
.dashboard__panel-link i { font-size: 10px; }
.dashboard__view-all:hover { color: var(--wp-text); }
.dashboard__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.dashboard__row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
}
.dashboard__row:hover { background: var(--wp-bg-3); }
.dashboard__row + .dashboard__row {
border-top: 1px solid var(--wp-border);
border-radius: 0;
}
.dashboard__row:focus-visible {
outline: 2px solid var(--wp-border-focus);
outline-offset: 2px;
}
.dashboard__row-icon {
width: 22px; height: 22px;
width: 22px;
height: 22px;
border-radius: 6px;
display: grid; place-items: center;
display: grid;
place-items: center;
font-size: 11px;
flex-shrink: 0;
}
@@ -579,6 +337,13 @@ onMounted(async () => {
.dashboard__empty {
font-size: 12.5px;
color: var(--wp-text-muted);
margin: 6px 4px 4px;
margin: 12px 4px 6px;
}
.dashboard__stat { cursor: pointer; text-align: left; font: inherit; color: inherit; }
.dashboard__stat:hover { border-color: var(--wp-border-strong); }
.dashboard__stat:focus-visible {
outline: 2px solid var(--wp-border-focus);
outline-offset: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+151 -3
View File
@@ -1,9 +1,157 @@
<script setup lang="ts">
/**
* Settings — port of `SettingsScreen` in
* `docs/design-handoff/wildcardpipeline/project/screens/utilities.jsx`.
* About / Theme / Storage cards using the local ui/* primitives.
*/
import Button from "../components/ui/Button.vue";
import Card from "../components/ui/Card.vue";
import Icon from "../components/ui/Icon.vue";
import { useUiStore, type ThemeMode } from "../stores/uiStore";
const uiStore = useUiStore();
const repoUrl = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline";
interface ThemeOption { value: ThemeMode; label: string; icon: string }
const THEMES: ThemeOption[] = [
{ value: "dark", label: "Dark", icon: "pi-moon" },
{ value: "light", label: "Light", icon: "pi-sun" },
{ value: "auto", label: "Auto", icon: "pi-desktop" },
];
function setTheme(mode: ThemeMode) {
uiStore.setThemeMode(mode);
}
function resetPreferences() {
// Best-effort — clear the manager's localStorage namespace + reload.
try {
const toClear: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("wp-")) toClear.push(key);
}
for (const k of toClear) localStorage.removeItem(k);
} catch {
/* ignore */
}
window.location.reload();
}
</script>
<template>
<div class="p-6 text-wp-text">
<h1 class="text-xl font-semibold m-0">Settings</h1>
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — full impl deferred (post-Plan-2).</p>
<div class="wp-page wp-settings">
<div class="wp-page__header">
<div class="wp-page__title-wrap">
<h1 class="wp-page__title">Settings</h1>
<p class="wp-page__subtitle">
Application preferences. Stored locally.
</p>
</div>
</div>
<Card title="About">
<div class="wp-settings__about">
<span class="wp-dim">Version</span><span class="wp-mono">1.4.x-dev</span>
<span class="wp-dim">License</span><span class="wp-mono">GPL-3.0-or-later</span>
<span class="wp-dim">Repository</span>
<a
class="wp-settings__link wp-mono"
:href="repoUrl"
target="_blank"
rel="noopener"
>
<Icon name="pi-github" /> {{ repoUrl }}
</a>
</div>
</Card>
<Card title="Theme">
<p class="wp-dim wp-settings__hint">
Choose dark, light, or follow the OS preference.
</p>
<div class="wp-settings__radio" role="radiogroup" aria-label="Theme">
<button
v-for="t in THEMES"
:key="t.value"
type="button"
class="wp-settings__chip"
role="radio"
:aria-checked="uiStore.themeMode === t.value"
:data-active="uiStore.themeMode === t.value ? 'true' : 'false'"
:data-test="`settings-theme-${t.value}`"
@click="setTheme(t.value)"
>
<Icon :name="t.icon" /> {{ t.label }}
</button>
</div>
</Card>
<Card title="Storage">
<p class="wp-dim wp-settings__hint">
Preferences (theme, sidebar state, last filters) are persisted in your
browser's <span class="wp-mono">localStorage</span>. Modules + categories
are stored server-side in SQLite.
</p>
<Button
variant="ghost"
icon="pi-refresh"
data-test="settings-reset"
@click="resetPreferences"
>Reset preferences</Button>
</Card>
</div>
</template>
<style scoped>
.wp-settings { max-width: 720px; }
.wp-settings__about {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
font-size: 12.5px;
align-items: center;
}
.wp-settings__link {
color: var(--wp-accent-text);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.wp-settings__link:hover { color: var(--wp-text); }
.wp-settings__hint {
font-size: 12.5px;
margin: 0 0 10px;
}
.wp-settings__radio {
display: inline-flex;
gap: 6px;
}
.wp-settings__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 7px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-2);
color: var(--wp-text-muted);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
}
.wp-settings__chip:hover {
color: var(--wp-text);
border-color: var(--wp-border-strong);
}
.wp-settings__chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
border-color: var(--wp-accent-500);
color: var(--wp-accent-text);
}
</style>
File diff suppressed because it is too large Load Diff