feat(spa): redesign Dashboard with brand hero + 6 kind stats + recent/favorites

Replace the basic Dashboard with the prototype layout: brand-gradient hero
card, six per-kind stat cards (wildcards/fixed/combine/derivation/constraint/
pipeline), pill-style quick-action row, and a two-column "Recently updated"
+ "Favorites" panel. Counts come from `api.modules.list({type, limit:1}).total`
in parallel; recent/favorite rows use the existing list endpoint with a
client-side updated_at sort (the API has no sort/order params yet — see TODO).
This commit is contained in:
DumiFlex
2026-04-27 04:13:11 +03:00
parent 1b7576a55f
commit 0dcb562801
2 changed files with 650 additions and 76 deletions
+96
View File
@@ -0,0 +1,96 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
vi.mock("../api/client", () => ({
api: {
modules: {
list: vi.fn(),
delete: vi.fn(),
duplicate: vi.fn(),
favorite: vi.fn(),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
ApiError: class extends Error {
constructor(public status: number, message: string) { super(message); }
},
}));
import { api } from "../api/client";
import Dashboard from "../views/Dashboard.vue";
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
});
afterEach(() => vi.clearAllMocks());
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/wildcards", name: "wildcards", component: { template: "<div/>" } },
{ path: "/wildcards/new", component: { template: "<div/>" } },
{ path: "/wildcards/:id/edit", name: "wildcards-edit", component: { template: "<div/>" } },
{ path: "/fixed-values", name: "fixed-values", component: { template: "<div/>" } },
{ path: "/fixed-values/new", component: { template: "<div/>" } },
{ path: "/combines", name: "combines", component: { template: "<div/>" } },
{ path: "/combines/new", component: { template: "<div/>" } },
{ path: "/derivations", name: "derivations", component: { template: "<div/>" } },
{ path: "/derivations/new", component: { template: "<div/>" } },
{ path: "/constraints", name: "constraints", component: { template: "<div/>" } },
{ path: "/constraints/new", component: { template: "<div/>" } },
{ path: "/pipelines", name: "pipelines", component: { template: "<div/>" } },
{ path: "/pipelines/new", component: { template: "<div/>" } },
{ path: "/categories", component: { template: "<div/>" } },
{ path: "/import-export", component: { template: "<div/>" } },
{ path: "/test", component: { template: "<div/>" } },
],
});
}
function mountView() {
return mount(Dashboard, {
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
});
}
describe("Dashboard.vue", () => {
it("renders the hero title", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("Welcome to Wildcard Pipeline");
});
it("shows 6 kind stat cards", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const wrap = mountView();
await flushPromises();
const stats = wrap.findAll(".dashboard__stat");
expect(stats).toHaveLength(6);
const text = wrap.text();
expect(text).toContain("Wildcards");
expect(text).toContain("Fixed Values");
expect(text).toContain("Combines");
expect(text).toContain("Derivations");
expect(text).toContain("Constraints");
expect(text).toContain("Pipelines");
});
it("renders without crashing when API returns empty arrays", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("No edits yet.");
expect(wrap.text()).toContain("No favorites yet.");
});
});
+554 -76
View File
@@ -1,106 +1,584 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import { useModuleStore } from "../stores/moduleStore";
import Menu from "primevue/menu";
import { api } from "../api/client";
import type { ModuleRow, ModuleType } from "../api/types";
import { useCategoryStore } from "../stores/categoryStore";
import RelativeDate from "../components/RelativeDate.vue";
const router = useRouter();
const moduleStore = useModuleStore();
const categoryStore = useCategoryStore();
const faviconUrl = `${import.meta.env.BASE_URL}images/favicon.svg`;
interface KindMeta {
type: ModuleType;
label: string;
icon: string;
color: string;
slug: string;
}
onMounted(async () => {
moduleStore.filter.type = undefined;
await Promise.all([moduleStore.fetchAll(), categoryStore.fetchAll()]);
// 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" },
];
const KIND_BY_TYPE: Record<ModuleType, KindMeta> = KIND_META.reduce((acc, k) => {
acc[k.type] = k;
return acc;
}, {} as Record<ModuleType, KindMeta>);
const router = useRouter();
const categoryStore = useCategoryStore();
const counts = ref<Record<ModuleType, number>>({
wildcard: 0,
fixed_values: 0,
combine: 0,
derivation: 0,
constraint: 0,
pipeline: 0,
});
const recent = computed(() => [...moduleStore.items].slice(0, 5));
const recentItems = ref<ModuleRow[]>([]);
const favoriteItems = ref<ModuleRow[]>([]);
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");
}
function navigateToKind(slug: string) {
router.push(`/${slug}`);
}
function editRow(row: ModuleRow) {
const meta = KIND_BY_TYPE[row.type];
if (!meta) return;
router.push(`/${meta.slug}/${row.id}/edit`);
}
const categoryById = computed(() => {
const map = new Map<string, { name: string; color: string | null }>();
for (const c of categoryStore.items) map.set(c.id, { name: c.name, color: c.color });
return map;
});
function categoryFor(row: ModuleRow): { name: string; color: string | null } | undefined {
if (!row.category_id) return undefined;
return categoryById.value.get(row.category_id);
}
async function loadCounts() {
// Fetch one record per kind in parallel; ModuleListResponse exposes total.
await Promise.all(
KIND_META.map(async (k) => {
try {
const res = await api.modules.list({ type: k.type, limit: 1 });
counts.value[k.type] = res.total ?? res.items.length;
} catch {
counts.value[k.type] = 0;
}
}),
);
}
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) => {
const ta = Date.parse(a.updated_at || "") || 0;
const tb = Date.parse(b.updated_at || "") || 0;
return tb - ta;
});
recentItems.value = sorted.slice(0, 5);
} catch {
recentItems.value = [];
}
}
async function loadFavorites() {
try {
const res = await api.modules.list({ favorites: true, limit: 5 });
favoriteItems.value = res.items.slice(0, 5);
} catch {
favoriteItems.value = [];
}
}
onMounted(async () => {
await Promise.all([
loadCounts(),
loadRecent(),
loadFavorites(),
categoryStore.fetchAll().catch(() => undefined),
]);
});
</script>
<template>
<div class="p-6 text-wp-text">
<section class="hero-card mb-6">
<img :src="faviconUrl" alt="" class="hero-card__logo" />
<div class="flex-1">
<h1 class="text-2xl font-semibold m-0">Welcome to Wildcard Pipeline</h1>
<p class="text-sm m-0 mt-1 opacity-90">
Manage modules — wildcards, fixed values — that drop into ComfyUI prompts as snapshots.
<div class="dashboard">
<!-- A) Brand hero -->
<section class="dashboard__hero">
<div class="dashboard__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.
</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>
</section>
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-card__label">Wildcards</div>
<div class="stat-card__value">{{ moduleStore.wildcards.length }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Fixed values</div>
<div class="stat-card__value">{{ moduleStore.fixedValues.length }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Categories</div>
<div class="stat-card__value">{{ categoryStore.items.length }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Total modules</div>
<div class="stat-card__value">{{ moduleStore.items.length }}</div>
</div>
</div>
<h2 class="text-sm uppercase tracking-wider text-wp-text2 mb-2">Recent edits</h2>
<div v-if="recent.length" class="bg-wp-bg2 border border-wp-border rounded">
<div
v-for="m in recent" :key="m.id"
class="flex items-center justify-between px-4 py-2 border-b border-wp-border last:border-b-0 cursor-pointer hover:bg-wp-bg3"
@click="router.push(m.type === 'wildcard' ? `/wildcards/${m.id}/edit` : `/fixed-values/${m.id}/edit`)"
<!-- B) Stat cards -->
<section class="dashboard__stats" aria-label="Module counts">
<button
v-for="kind in KIND_META"
:key="kind.type"
type="button"
class="dashboard__stat"
:aria-label="`View all ${kind.label}`"
@click="navigateToKind(kind.slug)"
>
<div class="flex items-center gap-3">
<i :class="m.type === 'wildcard' ? 'pi pi-th-large text-wp-violet' : 'pi pi-tag text-wp-rose'" />
<span class="font-medium">{{ m.name }}</span>
<span class="text-xs text-wp-text3 font-mono">{{ m.id }}</span>
</div>
<RelativeDate :value="m.updated_at" />
</div>
</div>
<p v-else class="text-sm text-wp-text2">No modules yet.</p>
<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>
</button>
</section>
<div class="mt-6 flex gap-2 flex-wrap">
<Button label="New wildcard" icon="pi pi-plus" severity="primary" @click="router.push('/wildcards/new')" />
<Button label="New fixed values" icon="pi pi-plus" severity="primary" outlined @click="router.push('/fixed-values/new')" />
<Button label="Open test runner" icon="pi pi-bolt" severity="secondary" outlined @click="router.push('/test')" />
</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>
<!-- 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)"
>
<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)"
>
<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>
</div>
</template>
<style scoped>
.hero-card {
display: flex; gap: 20px; align-items: center;
padding: 24px;
background: var(--wp-brand-gradient);
border-radius: var(--wp-radius);
.dashboard {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
color: var(--wp-text);
}
/* ---------- 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;
}
.hero-card__logo {
width: 64px;
height: 64px;
.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;
background: rgba(0, 0, 0, 0.35);
border-radius: 50%;
box-sizing: content-box;
display: flex;
flex-direction: column;
gap: 8px;
}
.dashboard__panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.dashboard__panel-title {
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
color: var(--wp-text);
}
.dashboard__panel-link {
font-size: 12px;
color: var(--wp-accent-text);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.dashboard__panel-link:hover { color: var(--wp-accent-text-strong); }
.dashboard__panel-link i { font-size: 10px; }
.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;
border-radius: 6px;
display: grid; place-items: center;
font-size: 11px;
flex-shrink: 0;
}
.stat-card {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 16px;
.dashboard__row-name {
flex: 1;
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.stat-card__label {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--wp-text2);
.dashboard__row-chip {
display: inline-block;
font-size: 10.5px;
padding: 2px 7px;
border-radius: 9px;
color: #fff;
font-weight: 500;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
flex-shrink: 0;
}
.dashboard__empty {
font-size: 12.5px;
color: var(--wp-text-muted);
margin: 6px 4px 4px;
}
.stat-card__value { font-size: 28px; font-weight: 600; margin-top: 4px; }
</style>