feat(spa): port prototype ui primitives + full styles.css (Wave 1)

Lays the foundation for the wholesale React/HTML→Vue manager port. Replaces the
partial tokens.css with a 1:1 port of the prototype's full styles.css and adds a
companion community.css. Introduces a hand-rolled wp-* primitive set under
src/manager/components/ui (Button, Icon, Card, Chip, Pill, Field, Input,
Textarea, Select, Checkbox, Toggle, Modal, ToastHost) plus a useToast
composable that replaces PrimeVue's toast service. App.vue swaps the PrimeVue
Toast for the new ToastHost.

Existing PrimeVue-based views are untouched; later waves will swap them.
This commit is contained in:
DumiFlex
2026-04-27 18:26:21 +03:00
parent 678a0eb403
commit 03d2b7bde5
28 changed files with 3603 additions and 144 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import Toast from "primevue/toast";
import ToastHost from "./components/ui/ToastHost.vue";
</script>
<template>
<RouterView />
<Toast position="bottom-right" />
<ToastHost />
</template>
+32
View File
@@ -0,0 +1,32 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Button from "../../components/ui/Button.vue";
describe("Button.vue", () => {
it("renders variant + size class", () => {
const wrap = mount(Button, {
props: { variant: "primary", size: "sm" },
slots: { default: "Save" },
});
const cls = wrap.get("button").classes();
expect(cls).toContain("wp-btn");
expect(cls).toContain("wp-btn--primary");
expect(cls).toContain("wp-btn--sm");
});
it("emits click and forwards ariaLabel", async () => {
const wrap = mount(Button, {
props: { ariaLabel: "Save changes" },
slots: { default: "Save" },
});
expect(wrap.get("button").attributes("aria-label")).toBe("Save changes");
await wrap.get("button").trigger("click");
expect(wrap.emitted("click")).toBeTruthy();
});
it("does not emit click when disabled", async () => {
const wrap = mount(Button, { props: { disabled: true }, slots: { default: "x" } });
await wrap.get("button").trigger("click");
expect(wrap.emitted("click")).toBeFalsy();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Card from "../../components/ui/Card.vue";
describe("Card.vue", () => {
it("renders title and actions slot", () => {
const wrap = mount(Card, {
props: { title: "Settings" },
slots: {
default: "<p>Body</p>",
actions: "<button data-test='action'>Edit</button>",
},
});
expect(wrap.get(".wp-card__title").text()).toBe("Settings");
expect(wrap.find("[data-test='action']").exists()).toBe(true);
});
it("omits header when no title and no actions slot", () => {
const wrap = mount(Card, { slots: { default: "<p>Just body</p>" } });
expect(wrap.find(".wp-card__header").exists()).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Checkbox from "../../components/ui/Checkbox.vue";
describe("Checkbox.vue", () => {
it("toggles modelValue when clicked", async () => {
const wrap = mount(Checkbox, { props: { modelValue: false, label: "Enable" } });
await wrap.get("button.wp-check").trigger("click");
expect(wrap.emitted("update:modelValue")?.[0]).toEqual([true]);
});
it("does not toggle when disabled", async () => {
const wrap = mount(Checkbox, { props: { modelValue: false, disabled: true } });
await wrap.get("button.wp-check").trigger("click");
expect(wrap.emitted("update:modelValue")).toBeFalsy();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Chip from "../../components/ui/Chip.vue";
describe("Chip.vue", () => {
it("emits remove when close button clicked", async () => {
const wrap = mount(Chip, {
props: { removable: true },
slots: { default: "tag" },
});
await wrap.get(".wp-chip__close").trigger("click");
expect(wrap.emitted("remove")).toBeTruthy();
});
it("applies tone class", () => {
const wrap = mount(Chip, { props: { tone: "success" }, slots: { default: "ok" } });
expect(wrap.get("span.wp-chip").classes()).toContain("wp-chip--success");
});
});
+23
View File
@@ -0,0 +1,23 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Field from "../../components/ui/Field.vue";
describe("Field.vue", () => {
it("renders label and forwards default slot", () => {
const wrap = mount(Field, {
props: { label: "Name" },
slots: { default: "<input data-test='in' />" },
});
expect(wrap.get("label").text()).toContain("Name");
expect(wrap.find("[data-test='in']").exists()).toBe(true);
});
it("renders error message and applies error class", () => {
const wrap = mount(Field, {
props: { label: "Name", error: "Required" },
slots: { default: "<input />" },
});
expect(wrap.get(".wp-field").classes()).toContain("wp-field--error");
expect(wrap.get(".wp-field__error").text()).toBe("Required");
});
});
+20
View File
@@ -0,0 +1,20 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Input from "../../components/ui/Input.vue";
describe("Input.vue", () => {
it("two-way binds via v-model", async () => {
const wrap = mount(Input, { props: { modelValue: "" } });
const el = wrap.get("input");
await el.setValue("hello");
const emitted = wrap.emitted("update:modelValue");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["hello"]);
});
it("renders icon addon when icon prop set", () => {
const wrap = mount(Input, { props: { modelValue: "x", icon: "search" } });
expect(wrap.find(".wp-input-group").exists()).toBe(true);
expect(wrap.find(".wp-input-group__addon .pi-search").exists()).toBe(true);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Modal from "../../components/ui/Modal.vue";
describe("Modal.vue", () => {
it("renders teleported when open=true", () => {
const wrap = mount(Modal, {
props: { open: true, title: "Confirm" },
slots: { default: "<p>Body</p>" },
attachTo: document.body,
});
expect(document.body.querySelector(".wp-modal")).not.toBeNull();
expect(document.body.querySelector(".wp-modal__title")?.textContent).toBe("Confirm");
wrap.unmount();
});
it("emits update:open=false on backdrop click", async () => {
const wrap = mount(Modal, {
props: { open: true },
slots: { default: "<p>x</p>" },
attachTo: document.body,
});
const backdrop = document.body.querySelector("[data-test='modal-backdrop']") as HTMLElement;
backdrop.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(wrap.emitted("update:open")?.[0]).toEqual([false]);
wrap.unmount();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Select from "../../components/ui/Select.vue";
const opts = [
{ value: "a", label: "Alpha" },
{ value: "b", label: "Beta" },
{ value: "c", label: "Gamma" },
];
describe("Select.vue", () => {
it("opens menu on click and renders options", async () => {
const wrap = mount(Select, { props: { modelValue: null, options: opts } });
expect(wrap.find(".wp-select__menu").exists()).toBe(false);
await wrap.get("[data-test='select-trigger']").trigger("click");
expect(wrap.find(".wp-select__menu").exists()).toBe(true);
expect(wrap.findAll(".wp-select__option").length).toBe(3);
});
it("Enter on highlighted option emits update:modelValue", async () => {
const wrap = mount(Select, { props: { modelValue: null, options: opts } });
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
// First option (index 0) is active by default.
await trigger.trigger("keydown", { key: "ArrowDown" });
await trigger.trigger("keydown", { key: "Enter" });
const events = wrap.emitted("update:modelValue");
expect(events).toBeTruthy();
expect(events![0]).toEqual(["b"]);
});
it("Esc closes the menu", async () => {
const wrap = mount(Select, { props: { modelValue: null, options: opts } });
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
expect(wrap.find(".wp-select__menu").exists()).toBe(true);
await trigger.trigger("keydown", { key: "Escape" });
expect(wrap.find(".wp-select__menu").exists()).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useToast } from "../../composables/useToast";
describe("useToast", () => {
beforeEach(() => {
vi.useFakeTimers();
// Drain the singleton stack between tests.
const { toasts } = useToast();
toasts.value.splice(0, toasts.value.length);
});
afterEach(() => {
vi.useRealTimers();
});
it("push appends an entry to the stack", () => {
const { toasts, push } = useToast();
push({ severity: "success", summary: "Saved" });
expect(toasts.value.length).toBe(1);
expect(toasts.value[0].summary).toBe("Saved");
});
it("auto-removes after life ms", () => {
const { toasts, push } = useToast();
push({ severity: "info", summary: "Hi", life: 1000 });
expect(toasts.value.length).toBe(1);
vi.advanceTimersByTime(1001);
expect(toasts.value.length).toBe(0);
});
it("dismiss removes by id", () => {
const { toasts, push, dismiss } = useToast();
const id = push({ severity: "warn", summary: "Heads up", life: 0 });
expect(toasts.value.length).toBe(1);
dismiss(id);
expect(toasts.value.length).toBe(0);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import Toggle from "../../components/ui/Toggle.vue";
describe("Toggle.vue", () => {
it("toggles modelValue", async () => {
const wrap = mount(Toggle, { props: { modelValue: true } });
await wrap.get("button.wp-toggle").trigger("click");
expect(wrap.emitted("update:modelValue")?.[0]).toEqual([false]);
});
it("renders the data-on attribute reflecting state", () => {
const wrap = mount(Toggle, { props: { modelValue: true } });
expect(wrap.get("button.wp-toggle").attributes("data-on")).toBe("true");
});
});
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, useSlots } from "vue";
import Icon from "./Icon.vue";
type Variant = "primary" | "secondary" | "ghost" | "danger" | "link" | "outline";
type Size = "sm" | "md" | "lg";
interface Props {
variant?: Variant;
size?: Size;
icon?: string;
iconRight?: string;
loading?: boolean;
disabled?: boolean;
type?: "button" | "submit" | "reset";
ariaLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
variant: "secondary",
size: "md",
type: "button",
});
const emit = defineEmits<{
(e: "click", evt: MouseEvent): void;
}>();
const slots = useSlots();
const hasContent = computed(() => Boolean(slots.default));
const classes = computed(() => [
"wp-btn",
`wp-btn--${props.variant}`,
`wp-btn--${props.size}`,
!hasContent.value && "wp-btn--icon",
]);
function onClick(e: MouseEvent) {
if (props.disabled || props.loading) return;
emit("click", e);
}
</script>
<template>
<button
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="ariaLabel"
:aria-busy="loading || undefined"
@click="onClick"
>
<Icon v-if="loading" name="spin pi-spinner" />
<Icon v-else-if="icon" :name="icon" />
<slot />
<Icon v-if="iconRight" :name="iconRight" />
</button>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { useSlots } from "vue";
interface Props {
title?: string;
subtitle?: string;
padding?: boolean;
}
withDefaults(defineProps<Props>(), { padding: true });
const slots = useSlots();
</script>
<template>
<section class="wp-card">
<header v-if="title || slots.actions" class="wp-card__header">
<h3 v-if="title" class="wp-card__title">{{ title }}</h3>
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
<span class="wp-spacer" />
<slot name="actions" />
</header>
<div :class="padding ? 'wp-card__body' : ''">
<slot />
</div>
</section>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
interface Props {
modelValue: boolean;
label?: string;
disabled?: boolean;
id?: string;
ariaLabel?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
}>();
function toggle() {
if (props.disabled) return;
emit("update:modelValue", !props.modelValue);
}
</script>
<template>
<label class="wp-checkbox" :class="{ 'is-disabled': disabled }">
<button
type="button"
class="wp-check"
:data-checked="modelValue ? 'true' : 'false'"
:aria-checked="modelValue"
:aria-label="ariaLabel ?? label"
:disabled="disabled"
role="checkbox"
@click="toggle"
>
<svg
v-if="modelValue"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
style="display: block"
>
<path
d="M3 6.2l2.2 2.2L9 4.4"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<span v-if="label" class="wp-checkbox__label">{{ label }}</span>
</label>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from "vue";
import Icon from "./Icon.vue";
type Tone = "default" | "accent" | "success" | "warn" | "danger" | "info";
interface Props {
tone?: Tone;
icon?: string;
removable?: boolean;
}
const props = withDefaults(defineProps<Props>(), { tone: "default" });
const emit = defineEmits<{
(e: "remove"): void;
}>();
const classes = computed(() => [
"wp-chip",
props.tone !== "default" && `wp-chip--${props.tone}`,
]);
function onRemove(e: MouseEvent) {
e.stopPropagation();
emit("remove");
}
</script>
<template>
<span :class="classes">
<Icon v-if="icon" :name="icon" :size="10" />
<slot />
<button
v-if="removable"
type="button"
class="wp-chip__close"
aria-label="Remove"
@click="onRemove"
>
<Icon name="times" :size="9" />
</button>
</span>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from "vue";
interface Props {
label?: string;
hint?: string;
error?: string;
for?: string;
required?: boolean;
}
const props = defineProps<Props>();
const classes = computed(() => [
"wp-field",
props.error && "wp-field--error",
]);
</script>
<template>
<div :class="classes">
<label v-if="label" :for="props.for" class="wp-field__label">
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
</label>
<slot />
<div v-if="error" class="wp-field__error" role="alert">{{ error }}</div>
<div v-else-if="hint" class="wp-field__hint">{{ hint }}</div>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from "vue";
interface Props {
name: string;
size?: number;
}
const props = defineProps<Props>();
const className = computed(() => {
// Accept either bare name ("plus", "check-circle") or already-prefixed ("pi-plus").
const n = props.name.startsWith("pi-") ? props.name : `pi-${props.name}`;
return `pi ${n}`;
});
const style = computed(() => (props.size ? { fontSize: `${props.size}px` } : undefined));
</script>
<template>
<i :class="className" :style="style" aria-hidden="true" />
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from "vue";
import Icon from "./Icon.vue";
interface Props {
modelValue: string | number;
type?: string;
placeholder?: string;
disabled?: boolean;
size?: "sm" | "md";
icon?: string;
addon?: string;
id?: string;
ariaLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
type: "text",
size: "md",
});
const emit = defineEmits<{
(e: "update:modelValue", v: string | number): void;
(e: "blur", evt: FocusEvent): void;
(e: "focus", evt: FocusEvent): void;
}>();
const inputClasses = computed(() => [
"wp-input",
props.size === "sm" && "wp-input--sm",
]);
const wrapped = computed(() => Boolean(props.icon || props.addon));
function onInput(e: Event) {
const target = e.target as HTMLInputElement;
// Coerce numeric inputs back to number for v-model parity.
if (props.type === "number" && target.value !== "") {
emit("update:modelValue", Number(target.value));
} else {
emit("update:modelValue", target.value);
}
}
</script>
<template>
<div v-if="wrapped" class="wp-input-group">
<span v-if="icon" class="wp-input-group__addon">
<Icon :name="icon" />
</span>
<input
:id="id"
:class="inputClasses"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel"
@input="onInput"
@blur="(e) => emit('blur', e)"
@focus="(e) => emit('focus', e)"
/>
<span v-if="addon" class="wp-input-group__addon wp-input-group__addon--right">{{ addon }}</span>
</div>
<input
v-else
:id="id"
:class="inputClasses"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel"
@input="onInput"
@blur="(e) => emit('blur', e)"
@focus="(e) => emit('focus', e)"
/>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, useSlots, watch } from "vue";
import Icon from "./Icon.vue";
interface Props {
open: boolean;
title?: string;
size?: "sm" | "md" | "lg";
closeOnBackdrop?: boolean;
}
const props = withDefaults(defineProps<Props>(), { size: "md", closeOnBackdrop: true });
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
}>();
const slots = useSlots();
const modalClasses = computed(() => ["wp-modal", `wp-modal--${props.size}`]);
function close() {
emit("update:open", false);
}
function onBackdrop() {
if (props.closeOnBackdrop) close();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
watch(() => props.open, (v) => {
if (typeof window === "undefined") return;
if (v) {
window.addEventListener("keydown", onKeydown);
} else {
window.removeEventListener("keydown", onKeydown);
}
}, { immediate: true });
onBeforeUnmount(() => {
if (typeof window !== "undefined") window.removeEventListener("keydown", onKeydown);
});
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="wp-modal__backdrop"
data-test="modal-backdrop"
@mousedown.self="onBackdrop"
>
<div :class="modalClasses" role="dialog" aria-modal="true" @mousedown.stop>
<header v-if="title || slots.header" class="wp-modal__head">
<h3 v-if="title" class="wp-modal__title">{{ title }}</h3>
<slot name="header" />
<span class="wp-spacer" />
<button
type="button"
class="wp-topbar__icon-btn"
aria-label="Close"
@click="close"
>
<Icon name="times" />
</button>
</header>
<div class="wp-modal__body">
<slot />
</div>
<footer v-if="slots.footer" class="wp-modal__foot">
<slot name="footer" />
</footer>
</div>
</div>
</Teleport>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from "vue";
import Icon from "./Icon.vue";
type Tone = "default" | "accent" | "success" | "warn" | "danger" | "info";
interface Props {
tone?: Tone;
icon?: string;
count?: number;
}
const props = withDefaults(defineProps<Props>(), { tone: "default" });
const classes = computed(() => [
"wp-pill",
props.tone !== "default" && `wp-pill--${props.tone}`,
]);
</script>
<template>
<span :class="classes">
<Icon v-if="icon" :name="icon" :size="10" />
<slot />
<span v-if="count !== undefined" class="wp-pill__count">{{ count }}</span>
</span>
</template>
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import Icon from "./Icon.vue";
export interface SelectOption {
value: string | number | null;
label: string;
}
interface Props {
modelValue: string | number | null;
options: SelectOption[];
placeholder?: string;
clearable?: boolean;
size?: "sm" | "md";
disabled?: boolean;
ariaLabel?: string;
}
const props = withDefaults(defineProps<Props>(), { size: "md", placeholder: "Select…" });
const emit = defineEmits<{
(e: "update:modelValue", v: string | number | null): void;
(e: "change", v: string | number | null): void;
}>();
const wrapRef = ref<HTMLDivElement | null>(null);
const btnRef = ref<HTMLButtonElement | null>(null);
const open = ref(false);
const flip = ref(false);
const active = ref(0);
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
const btnClasses = computed(() => [
"wp-select",
props.size === "sm" && "wp-select--sm",
]);
function onDocClick(e: MouseEvent) {
if (!wrapRef.value) return;
if (!wrapRef.value.contains(e.target as Node)) open.value = false;
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
});
onBeforeUnmount(() => {
document.removeEventListener("mousedown", onDocClick);
});
watch(open, (v) => {
if (!v || !btnRef.value) return;
// Decide if the menu should flip above the button.
const rect = btnRef.value.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const need = Math.min(240, props.options.length * 30 + 12);
flip.value = spaceBelow < need + 16 && spaceAbove > spaceBelow;
// Highlight currently-selected option (or first).
const idx = props.options.findIndex((o) => o.value === props.modelValue);
active.value = idx >= 0 ? idx : 0;
});
function toggle() {
if (props.disabled) return;
open.value = !open.value;
}
function pick(opt: SelectOption) {
emit("update:modelValue", opt.value);
emit("change", opt.value);
open.value = false;
nextTick(() => btnRef.value?.focus());
}
function clear(e: MouseEvent) {
e.stopPropagation();
emit("update:modelValue", null);
emit("change", null);
}
function onKeydown(e: KeyboardEvent) {
if (props.disabled) return;
if (!open.value) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
open.value = true;
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
open.value = false;
} else if (e.key === "ArrowDown") {
e.preventDefault();
active.value = (active.value + 1) % props.options.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
active.value = (active.value - 1 + props.options.length) % props.options.length;
} else if (e.key === "Enter") {
e.preventDefault();
const opt = props.options[active.value];
if (opt) pick(opt);
}
}
</script>
<template>
<div ref="wrapRef" class="wp-select-wrap">
<button
ref="btnRef"
type="button"
:class="btnClasses"
:disabled="disabled"
:aria-label="ariaLabel"
:aria-expanded="open"
:aria-haspopup="'listbox'"
data-test="select-trigger"
@click="toggle"
@keydown="onKeydown"
>
<span v-if="selected">{{ selected.label }}</span>
<span v-else class="wp-select__placeholder">{{ placeholder }}</span>
<span class="wp-spacer" />
<button
v-if="clearable && selected"
type="button"
class="wp-chip__close"
aria-label="Clear"
@click="clear"
>
<Icon name="times" :size="10" />
</button>
<Icon class="wp-select__chevron" name="chevron-down" />
</button>
<ul
v-if="open"
class="wp-select__menu"
role="listbox"
:data-flip="flip ? 'true' : 'false'"
>
<li
v-for="(opt, i) in options"
:key="String(opt.value)"
class="wp-select__option"
role="option"
tabindex="-1"
:aria-selected="opt.value === modelValue"
:data-active="i === active ? 'true' : 'false'"
:data-selected="opt.value === modelValue ? 'true' : 'false'"
@mousedown.prevent="pick(opt)"
@mouseenter="active = i"
@focusin="active = i"
>
{{ opt.label }}
<span class="wp-spacer" />
<Icon v-if="opt.value === modelValue" name="check" :size="11" />
</li>
</ul>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
interface Props {
modelValue: string;
rows?: number;
placeholder?: string;
autoResize?: boolean;
disabled?: boolean;
id?: string;
ariaLabel?: string;
}
const props = withDefaults(defineProps<Props>(), { rows: 3 });
const emit = defineEmits<{
(e: "update:modelValue", v: string): void;
(e: "blur", evt: FocusEvent): void;
(e: "focus", evt: FocusEvent): void;
}>();
const taRef = ref<HTMLTextAreaElement | null>(null);
function autosize() {
if (!props.autoResize) return;
const el = taRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function onInput(e: Event) {
emit("update:modelValue", (e.target as HTMLTextAreaElement).value);
if (props.autoResize) nextTick(autosize);
}
watch(() => props.modelValue, () => {
if (props.autoResize) nextTick(autosize);
});
</script>
<template>
<textarea
:id="id"
ref="taRef"
class="wp-textarea"
:rows="rows"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel"
@input="onInput"
@blur="(e) => emit('blur', e)"
@focus="(e) => emit('focus', e)"
/>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useToast } from "../../composables/useToast";
import Icon from "./Icon.vue";
const { toasts, dismiss } = useToast();
function severityIcon(s: string): string {
switch (s) {
case "success":
return "check-circle";
case "warn":
return "exclamation-triangle";
case "error":
return "times-circle";
default:
return "info-circle";
}
}
</script>
<template>
<div class="wp-toast-stack" aria-live="polite" aria-atomic="true">
<div
v-for="t in toasts"
:key="t.id"
:class="['wp-toast', `wp-toast--${t.severity}`]"
role="status"
data-test="toast"
>
<Icon :name="severityIcon(t.severity)" />
<div>
<div class="wp-toast__summary">{{ t.summary }}</div>
<div v-if="t.detail" class="wp-toast__detail">{{ t.detail }}</div>
</div>
<button
type="button"
class="wp-toast__close"
aria-label="Dismiss"
@click="dismiss(t.id)"
>
<Icon name="times" :size="11" />
</button>
</div>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
interface Props {
modelValue: boolean;
label?: string;
disabled?: boolean;
ariaLabel?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
}>();
function toggle() {
if (props.disabled) return;
emit("update:modelValue", !props.modelValue);
}
</script>
<template>
<label class="wp-toggle-row" :class="{ 'is-disabled': disabled }">
<button
type="button"
class="wp-toggle"
:data-on="modelValue ? 'true' : 'false'"
:aria-checked="modelValue"
:aria-label="ariaLabel ?? label"
:disabled="disabled"
role="switch"
@click="toggle"
/>
<span v-if="label" class="wp-toggle-row__label">{{ label }}</span>
</label>
</template>
+42
View File
@@ -0,0 +1,42 @@
import { ref } from "vue";
export type ToastSeverity = "info" | "success" | "warn" | "error";
export interface ToastEntry {
id: string;
severity: ToastSeverity;
summary: string;
detail?: string;
life?: number;
}
const DEFAULT_LIFE = 2400;
// Module-level singleton — every call to useToast() shares the same stack so
// any view can push and the single <ToastHost /> mounted in App.vue renders.
const toasts = ref<ToastEntry[]>([]);
let nextId = 0;
function makeId() {
nextId += 1;
return `t${Date.now().toString(36)}_${nextId}`;
}
function dismiss(id: string) {
const i = toasts.value.findIndex((t) => t.id === id);
if (i >= 0) toasts.value.splice(i, 1);
}
function push(entry: Omit<ToastEntry, "id">): string {
const id = makeId();
const life = entry.life ?? DEFAULT_LIFE;
toasts.value.push({ ...entry, id });
if (life > 0) {
setTimeout(() => dismiss(id), life);
}
return id;
}
export function useToast() {
return { toasts, push, dismiss };
}
+1
View File
@@ -6,6 +6,7 @@ import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
import "primeicons/primeicons.css";
import "./styles/theme.css";
import "./styles/community.css";
import "./styles/tailwind.css";
import App from "./App.vue";
import router from "./router";
+455
View File
@@ -0,0 +1,455 @@
/* =========================================================
Community styles — appended after the rest of styles.css
1:1 port of docs/design-handoff/wildcardpipeline/project/styles-community.css.
========================================================= */
/* ---------- Featured strip ---------- */
.wp-comm-featured { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.wp-comm-featured__head { display: flex; align-items: baseline; justify-content: space-between; }
.wp-comm-featured__label {
margin: 0; font-size: 12px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--wp-text-muted);
display: inline-flex; align-items: center; gap: 6px;
}
.wp-comm-featured__label .pi { color: var(--wp-accent-text); }
.wp-comm-featured__hint { font-size: 11px; color: var(--wp-text-dim); }
.wp-comm-featured__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
}
.wp-comm-feat {
position: relative; min-height: 160px;
border-radius: var(--wp-radius-lg);
border: 1px solid var(--wp-border);
padding: 0; overflow: hidden;
cursor: pointer; text-align: left;
color: #fff;
transition: transform .15s ease, box-shadow .15s ease;
}
.wp-comm-feat::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0) 30%, rgba(0,0,0,0.65) 100%);
pointer-events: none;
}
.wp-comm-feat:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
.wp-comm-feat__inner {
position: relative; z-index: 1;
height: 100%; padding: 14px;
display: flex; flex-direction: column; justify-content: space-between;
gap: 10px;
}
.wp-comm-feat h3 { margin: 0; font-size: 16px; font-weight: 700; }
.wp-comm-feat p { margin: 0; font-size: 12px; color: rgba(255,255,255,0.84); }
.wp-comm-feat__stats {
display: flex; gap: 10px; font-size: 11px; color: rgba(255,255,255,0.92);
}
.wp-comm-feat__stats .pi { margin-right: 3px; }
/* ---------- Filter bar + tag rail ---------- */
.wp-comm-filters {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
padding: 10px; margin-bottom: 8px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
}
.wp-comm-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--wp-text-muted); cursor: pointer;
padding: 0 6px;
}
.wp-comm-toggle--nsfw { color: #fcd34d; }
.wp-comm-tagrail {
display: flex; gap: 6px; flex-wrap: wrap;
margin-bottom: 12px;
}
/* ---------- Discover grid + cards ---------- */
.wp-comm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.wp-comm-card {
display: flex; flex-direction: column;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
text-align: left;
}
.wp-comm-card:hover {
transform: translateY(-1px);
border-color: var(--wp-border-strong);
box-shadow: 0 6px 18px rgba(0,0,0,0.28);
}
.wp-comm-card__hero {
position: relative;
height: 100px;
display: flex; align-items: flex-start; justify-content: space-between;
padding: 8px;
}
.wp-comm-card__kind {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px; border-radius: 999px;
background: rgba(0,0,0,0.42);
border: 1px solid rgba(255,255,255,0.16);
color: #fff; font-size: 10px; font-weight: 600;
}
.wp-comm-card__nsfw, .wp-comm-card__incompat {
padding: 3px 8px; border-radius: 999px;
background: rgba(0,0,0,0.42);
border: 1px solid rgba(255,255,255,0.16);
font-size: 10px; font-weight: 600;
color: #fff;
}
.wp-comm-card__nsfw { background: rgba(190, 18, 60, 0.85); border-color: rgba(255,255,255,0.2); }
.wp-comm-card__incompat { background: rgba(245, 158, 11, 0.85); border-color: rgba(255,255,255,0.2); margin-left: 4px; }
.wp-comm-card__body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 6px; }
.wp-comm-card__title-row { display: flex; align-items: flex-start; gap: 6px; }
.wp-comm-card__title { margin: 0; flex: 1; font-size: 14px; font-weight: 700; line-height: 1.25; }
.wp-comm-card__star {
background: none; border: none; padding: 2px 4px; cursor: pointer;
color: var(--wp-text-dim); font-size: 14px;
}
.wp-comm-card__star[data-on="true"] { color: #fbbf24; }
.wp-comm-card__star:hover { color: #fbbf24; }
.wp-comm-card__tagline { margin: 0; font-size: 12px; color: var(--wp-text-muted); line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.wp-comm-card__author { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.wp-comm-card__stats {
display: flex; gap: 10px; font-size: 11px; color: var(--wp-text-muted);
align-items: center; padding-top: 4px;
border-top: 1px solid var(--wp-border);
}
.wp-comm-card__stats span { display: inline-flex; align-items: center; gap: 3px; }
.wp-comm-card__tags { display: flex; flex-wrap: wrap; gap: 3px; }
.wp-comm-card__installed {
display: inline-flex; align-items: center; gap: 4px; align-self: flex-start;
font-size: 11px; color: #86efac; font-weight: 600;
padding: 2px 8px; border-radius: 999px;
background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.32);
}
/* Skeleton */
.wp-skel {
background: linear-gradient(90deg, var(--wp-bg-2) 0%, var(--wp-bg-3) 50%, var(--wp-bg-2) 100%);
background-size: 200% 100%;
border-radius: 6px;
animation: wp-skel-shimmer 1.4s linear infinite;
}
@keyframes wp-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.wp-comm-card--skeleton { pointer-events: none; cursor: default; }
.wp-comm-card--skeleton:hover { transform: none; box-shadow: none; }
/* ---------- Detail page ---------- */
.wp-comm-detail-hero {
position: relative;
border-radius: var(--wp-radius-lg);
border: 1px solid var(--wp-border);
overflow: hidden;
color: #fff;
margin-bottom: 12px;
}
.wp-comm-detail-hero::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.55) 100%);
pointer-events: none;
}
.wp-comm-detail-hero__inner {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; justify-content: space-between;
padding: 24px;
min-height: 200px;
}
.wp-comm-detail-hero__title { margin: 0; font-size: 28px; font-weight: 800; line-height: 1.1; }
.wp-comm-detail-hero__tagline { margin: 6px 0 12px 0; font-size: 14px; color: rgba(255,255,255,0.86); max-width: 640px; }
.wp-comm-detail-hero__meta {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
font-size: 12px; color: rgba(255,255,255,0.86);
}
.wp-comm-detail-hero__meta .pi { margin-right: 4px; }
.wp-comm-detail-hero__author {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: rgba(0,0,0,0.32);
border: 1px solid rgba(255,255,255,0.18);
color: #fff; cursor: pointer;
font-size: 12px;
}
.wp-comm-detail-hero__author:hover { background: rgba(0,0,0,0.5); }
.wp-comm-detail-hero__actions {
display: flex; flex-wrap: wrap; gap: 8px;
}
.wp-comm-detail-hero__actions .wp-btn {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.2);
color: #fff;
}
.wp-comm-detail-hero__actions .wp-btn:hover { background: rgba(255,255,255,0.2); }
.wp-comm-detail-hero__actions .wp-btn--primary {
background: rgba(255,255,255,0.94); color: var(--wp-accent-700, #5b21b6);
border-color: transparent;
}
.wp-comm-detail-hero__actions .wp-btn--primary:hover { background: #fff; }
.wp-comm-detail-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1px;
background: var(--wp-border);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
overflow: hidden; margin-bottom: 12px;
}
.wp-comm-detail-stats > div {
background: var(--wp-bg-1);
padding: 10px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.wp-comm-detail-stats span { font-size: 11px; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.wp-comm-detail-stats strong { font-size: 16px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.wp-comm-detail-stats__version select { width: 100%; }
/* Tabs */
.wp-comm-tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--wp-border);
margin-bottom: 12px;
overflow-x: auto;
}
.wp-comm-tab {
background: none; border: none; padding: 10px 14px;
color: var(--wp-text-muted); font-size: 13px; font-weight: 600; cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.wp-comm-tab:hover { color: var(--wp-text); }
.wp-comm-tab[data-active="true"] {
color: var(--wp-text);
border-bottom-color: var(--wp-accent-500);
}
/* README */
.wp-comm-readme h2 { font-size: 20px; margin: 16px 0 8px 0; }
.wp-comm-readme h3 { font-size: 15px; margin: 14px 0 6px 0; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.wp-comm-readme p { margin: 0 0 8px 0; font-size: 13px; line-height: 1.6; }
.wp-comm-readme ul { margin: 0 0 8px 0; padding-left: 20px; }
.wp-comm-readme li { font-size: 13px; line-height: 1.6; }
.wp-comm-readme code {
background: var(--wp-bg-2); border: 1px solid var(--wp-border);
padding: 1px 5px; border-radius: 4px; font-size: 12px; font-family: var(--wp-mono, "JetBrains Mono", monospace);
}
/* Preview tab */
.wp-comm-preview .wp-pack-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: 6px;
font-size: 12px;
}
.wp-comm-sample {
font-family: var(--wp-mono, "JetBrains Mono", monospace);
font-size: 12px; padding: 6px 0;
border-bottom: 1px solid var(--wp-border);
color: var(--wp-text);
}
.wp-comm-sample:last-child { border-bottom: none; }
.wp-comm-histo-row {
display: grid; grid-template-columns: minmax(140px, 240px) 1fr 40px;
gap: 10px; align-items: center;
padding: 4px 0;
}
.wp-comm-histo-row__val {
font-family: var(--wp-mono, "JetBrains Mono", monospace);
font-size: 12px; color: var(--wp-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wp-comm-histo-row__bar {
height: 8px; background: var(--wp-bg-2); border-radius: 4px;
border: 1px solid var(--wp-border);
overflow: hidden;
}
.wp-comm-histo-row__bar > div {
height: 100%;
background: linear-gradient(90deg, var(--wp-accent-600), var(--wp-accent-400));
}
.wp-comm-histo-row__pct { font-size: 11px; color: var(--wp-text-muted); text-align: right; }
/* Versions */
.wp-comm-versions { display: flex; flex-direction: column; gap: 8px; }
.wp-comm-version {
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
background: var(--wp-bg-1);
overflow: hidden;
}
.wp-comm-version[data-active="true"] { border-color: var(--wp-accent-500); }
.wp-comm-version__head {
width: 100%; background: none; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px;
font-size: 14px; font-weight: 600; color: var(--wp-text);
}
.wp-comm-version__num { display: inline-flex; align-items: center; }
.wp-comm-version__date { font-size: 12px; color: var(--wp-text-muted); font-weight: 400; }
.wp-comm-version__notes {
margin: 0; padding: 0 14px 12px 14px;
font-size: 13px; color: var(--wp-text-muted); line-height: 1.5;
}
/* Reviews */
.wp-comm-reviews { display: flex; flex-direction: column; gap: 12px; }
.wp-comm-reviews__summary {
display: grid; grid-template-columns: 160px 1fr; gap: 16px;
padding: 16px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
}
.wp-comm-reviews__big { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.wp-comm-reviews__big-num { font-size: 40px; font-weight: 800; line-height: 1; }
.wp-comm-reviews__bars { display: flex; flex-direction: column; gap: 4px; justify-content: center; }
.wp-comm-reviews__bar { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--wp-text-muted); }
.wp-comm-reviews__bar-track {
flex: 1; height: 8px; background: var(--wp-bg-2);
border: 1px solid var(--wp-border); border-radius: 4px; overflow: hidden;
}
.wp-comm-reviews__bar-track > div { height: 100%; background: #fbbf24; }
.wp-comm-reviews__form textarea { resize: vertical; min-height: 60px; }
.wp-comm-reviews__list { display: flex; flex-direction: column; gap: 8px; }
.wp-comm-review {
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
padding: 12px;
}
.wp-comm-review__head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.wp-comm-review__text { margin: 0; font-size: 13px; line-height: 1.5; }
/* Stepper */
.wp-comm-stepper {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
padding: 12px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
}
.wp-comm-step {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px; border-radius: 999px;
background: var(--wp-bg-2);
font-size: 12px; font-weight: 600; color: var(--wp-text-muted);
}
.wp-comm-step__num {
width: 20px; height: 20px; border-radius: 999px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--wp-bg-3); color: var(--wp-text-muted);
font-size: 11px;
}
.wp-comm-step[data-active="true"] { background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent); color: var(--wp-accent-text-strong); }
.wp-comm-step[data-active="true"] .wp-comm-step__num { background: var(--wp-accent-500); color: #fff; }
.wp-comm-step[data-done="true"] .wp-comm-step__num { background: var(--wp-success); color: #fff; }
/* Atom picker */
.wp-comm-atom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-comm-atom {
text-align: left; padding: 16px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-2);
border-radius: var(--wp-radius-lg);
cursor: pointer; transition: border-color .12s, background .12s;
display: flex; flex-direction: column; gap: 6px;
}
.wp-comm-atom .pi { font-size: 22px; color: var(--wp-accent-text); }
.wp-comm-atom strong { font-size: 14px; }
.wp-comm-atom span { font-size: 12px; color: var(--wp-text-muted); }
.wp-comm-atom:hover { border-color: var(--wp-border-strong); }
.wp-comm-atom[data-active="true"] {
border-color: var(--wp-accent-500);
background: color-mix(in oklab, var(--wp-accent-500) 8%, var(--wp-bg-2));
}
/* Form */
.wp-comm-form { display: flex; flex-direction: column; gap: 10px; }
.wp-comm-form__row { display: flex; flex-direction: column; gap: 4px; }
.wp-comm-form__row--split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px;
}
.wp-comm-review-step { display: flex; flex-direction: column; gap: 12px; }
.wp-comm-review-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px;
background: var(--wp-border);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
overflow: hidden;
}
.wp-comm-review-grid > div {
background: var(--wp-bg-2);
padding: 8px 12px;
display: flex; flex-direction: column; gap: 2px;
}
.wp-comm-review-grid span { font-size: 11px; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.wp-comm-review-grid strong { font-size: 13px; }
/* Profile */
.wp-comm-profile-hero {
display: flex; align-items: center; gap: 16px;
padding: 16px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
flex-wrap: wrap;
}
.wp-comm-profile-avatar {
width: 64px; height: 64px; border-radius: 999px;
background: linear-gradient(135deg, var(--wp-accent-500), var(--wp-accent-700));
display: flex; align-items: center; justify-content: center;
font-size: 32px;
}
.wp-comm-profile-stats {
display: flex; gap: 16px; margin-top: 8px; flex-wrap: wrap;
}
.wp-comm-profile-stats > div { display: flex; flex-direction: column; }
.wp-comm-profile-stats strong { font-size: 18px; font-weight: 700; }
.wp-comm-profile-stats span { font-size: 11px; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
/* Modal */
.wp-modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 24px;
}
.wp-modal {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
width: 100%; max-width: 520px;
max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.wp-modal__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--wp-border);
}
.wp-modal__head h3 { margin: 0; font-size: 16px; }
.wp-modal__body { padding: 16px; overflow-y: auto; }
.wp-modal__foot {
display: flex; gap: 8px; justify-content: flex-end;
padding: 12px 16px; border-top: 1px solid var(--wp-border);
}
.wp-radio-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; cursor: pointer; }
/* Light theme tweaks */
:root.wp-theme-light .wp-comm-card { background: #fff; }
:root.wp-theme-light .wp-comm-version,
:root.wp-theme-light .wp-comm-review,
:root.wp-theme-light .wp-comm-reviews__summary,
:root.wp-theme-light .wp-comm-stepper,
:root.wp-theme-light .wp-comm-profile-hero,
:root.wp-theme-light .wp-comm-filters,
:root.wp-theme-light .wp-modal { background: #fff; }
File diff suppressed because it is too large Load Diff