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:
+2
-2
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
+2144
-142
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user