fix(spa): light-mode + dropdown polish for chips, pills, selects

- Add rgba() fallbacks before color-mix(in oklab) for older embedded Chromium
- Add .wp-theme-light overrides with darker text + stronger tints for chips
  (accent/success/warn/danger/info), syntax pills (ref/in/dp), tag-chip
  active, syntax-link missing, rich-text tokens (var/ref/quantifier),
  ConstraintMatrix data-mode states, HistoryPanel active item
- Bump dynamic catChip / Chip color-mix percentages 14% to 18% bg, 35% to 42%
  border (inline JS styles can't use CSS fallback pattern)
- Select.vue dropdown:
  - Teleport menu to body so it escapes overflow:hidden + sticky footer
    z-index; position:fixed + getBoundingClientRect placement, flip when no
    space below; menu z-index 9999
  - Remove min-width:0 from .wp-select__option-label so menu grows to fit
    longest option (fixes 'Updated --- newest' clipping)
  - Drop wp-spacer from trigger so single flex:1 element gets the space
  - Update Select.test.ts to query document.body for teleported menu
- RichTextInput autocomplete:
  - Remove duplicate '@' glyph (was rendering pi-at icon AND text trigger)
  - Bump z-index to 9999, lighter bg, full-width head bleed
- Add color dots to category dropdowns in IdentityCard + all 6 list views
- Standardize ImportExport category chip to shared catChipStyle()
- Fix Wildcards syntax-pill--ref color: #a78bfa (kind violet) to #c026d3
  (magenta, matching @ref token color)
This commit is contained in:
DumiFlex
2026-04-28 01:14:53 +03:00
parent c246360b97
commit cd8d23a750
22 changed files with 687 additions and 272 deletions
+30 -10
View File
@@ -1,5 +1,5 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import Select from "../../components/ui/Select.vue";
const opts = [
@@ -8,33 +8,53 @@ const opts = [
{ value: "c", label: "Gamma" },
];
// The menu is teleported to <body>, so queries must use `document` rather than
// the test-utils wrapper. `attachTo: document.body` plus `wrap.unmount()` keeps
// teleported nodes from leaking between tests.
describe("Select.vue", () => {
afterEach(() => {
document.body.innerHTML = "";
});
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);
const wrap = mount(Select, {
props: { modelValue: null, options: opts },
attachTo: document.body,
});
expect(document.querySelector(".wp-select__menu")).toBeNull();
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);
expect(document.querySelector(".wp-select__menu")).not.toBeNull();
expect(document.querySelectorAll(".wp-select__option").length).toBe(3);
wrap.unmount();
});
it("Enter on highlighted option emits update:modelValue", async () => {
const wrap = mount(Select, { props: { modelValue: null, options: opts } });
const wrap = mount(Select, {
props: { modelValue: null, options: opts },
attachTo: document.body,
});
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
// First option (index 0) is active by default.
// First option (index 0) is active by default; ArrowDown moves to index 1 ("b").
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"]);
wrap.unmount();
});
it("Esc closes the menu", async () => {
const wrap = mount(Select, { props: { modelValue: null, options: opts } });
const wrap = mount(Select, {
props: { modelValue: null, options: opts },
attachTo: document.body,
});
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
expect(wrap.find(".wp-select__menu").exists()).toBe(true);
expect(document.querySelector(".wp-select__menu")).not.toBeNull();
await trigger.trigger("keydown", { key: "Escape" });
expect(wrap.find(".wp-select__menu").exists()).toBe(false);
expect(document.querySelector(".wp-select__menu")).toBeNull();
wrap.unmount();
});
});
+9 -7
View File
@@ -57,13 +57,15 @@ function onKey(event: KeyboardEvent) {
<i :class="['pi', KIND_ICON[kindKey]]" aria-hidden="true" />
{{ KIND_LABEL[kindKey] }}
</span>
<span v-if="atom.nsfw" class="wp-comm-card__nsfw">18+</span>
<span
v-if="!compatible"
class="wp-comm-card__incompat"
:title="`Needs engine >= ${atom.engine_min_version}`"
>
engine ≥ {{ atom.engine_min_version }}
<span class="wp-comm-card__hero-right">
<span v-if="atom.nsfw" class="wp-comm-card__nsfw">18+</span>
<span
v-if="!compatible"
class="wp-comm-card__incompat"
:title="`Needs engine >= ${atom.engine_min_version}`"
>
engine ≥ {{ atom.engine_min_version }}
</span>
</span>
</div>
<div class="wp-comm-card__body">
+72 -39
View File
@@ -48,6 +48,8 @@
border-radius: 999px;
background: var(--wp-bg-3);
user-select: none;
cursor: pointer;
border: 1px solid transparent;
}
.wp-comm-filters__toggle--nsfw { color: var(--wp-warn); }
@@ -83,28 +85,27 @@
display: flex;
flex-direction: column;
gap: 10px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
padding: 14px;
}
.wp-comm-featured__head {
display: flex;
justify-content: space-between;
align-items: center;
align-items: baseline;
}
.wp-comm-featured__label {
display: inline-flex; gap: 8px; align-items: center;
margin: 0; font-size: 13px; font-weight: 600;
color: var(--wp-text);
display: inline-flex; gap: 6px; align-items: center;
margin: 0; font-size: 12px; font-weight: 700;
color: var(--wp-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.wp-comm-featured__label .pi { color: var(--wp-accent-text); }
.wp-comm-featured__hint {
font-size: 11px;
color: var(--wp-text-muted);
color: var(--wp-text-dim);
}
.wp-comm-featured__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
}
@media (max-width: 1024px) { .wp-comm-featured__row { grid-template-columns: 1fr 1fr; } }
@@ -113,7 +114,7 @@
.wp-comm-feat {
position: relative;
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
border-radius: var(--wp-radius-lg);
overflow: hidden;
text-align: left;
cursor: pointer;
@@ -123,17 +124,37 @@
color: #fff;
font: inherit;
padding: 0;
transition: transform .15s ease, box-shadow .15s ease;
}
.wp-comm-feat:hover { transform: translateY(-1px); }
/* Gradient overlay — rendered as pseudo so the inner flex column sits above it. */
.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;
display: flex;
flex-direction: column;
gap: 6px;
background: linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.45) 100%);
gap: 10px;
padding: 14px;
width: 100%;
height: 100%;
justify-content: space-between;
}
.wp-comm-feat h3 { margin: 0; font-size: 16px; }
/* Kind chip used inside featured strip — inline flex flow (NOT absolutely-positioned).
Separate from .wp-comm-card__kind which uses position:absolute for the card grid hero. */
.wp-comm-feat__kind {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px; border-radius: 999px;
background: rgba(0,0,0,0.40);
border: 1px solid rgba(255,255,255,0.18);
color: #fff; font-size: 10px; font-weight: 600;
align-self: flex-start;
}
.wp-comm-feat h3 { margin: 0; font-size: 16px; font-weight: 700; }
.wp-comm-feat p {
margin: 0;
font-size: 12px;
@@ -142,12 +163,12 @@
.wp-comm-feat__stats {
margin-top: auto;
display: flex;
gap: 12px;
gap: 10px;
font-size: 11px;
color: rgba(255,255,255,0.92);
align-items: center;
}
.wp-comm-feat__stats i { margin-right: 4px; }
.wp-comm-feat__stats i { margin-right: 3px; }
/* ---------- Card grid ---------- */
.wp-comm-grid {
@@ -178,36 +199,41 @@
outline-offset: 2px;
}
.wp-comm-card__hero {
height: 84px;
height: 100px;
position: relative;
display: flex; align-items: flex-start; justify-content: space-between;
padding: 8px;
}
/* Kind chip in the card grid hero — part of flex flow (left side). */
.wp-comm-card__kind {
position: absolute; top: 8px; left: 8px;
display: inline-flex; gap: 4px; align-items: center;
font-size: 11px;
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px; border-radius: 999px;
background: rgba(0,0,0,0.42);
color: #fff;
border-radius: 999px;
padding: 2px 8px;
border: 1px solid rgba(255,255,255,0.16);
color: #fff; font-size: 10px; font-weight: 600;
}
/* Right-side wrapper groups nsfw + incompat badges. */
.wp-comm-card__hero-right {
display: flex; gap: 4px; align-items: flex-start;
}
.wp-comm-card__nsfw {
position: absolute; top: 8px; right: 8px;
display: inline-flex;
padding: 3px 8px; border-radius: 999px;
font-size: 10px; font-weight: 600;
background: var(--wp-warn);
color: #1c1c28;
border-radius: 999px;
padding: 1px 6px;
background: rgba(190, 18, 60, 0.85);
border: 1px solid rgba(255,255,255,0.20);
color: #fff;
}
.wp-comm-card__incompat {
position: absolute; bottom: 8px; right: 8px;
display: inline-flex;
padding: 3px 8px; border-radius: 999px;
font-size: 10px; font-weight: 600;
background: rgba(239, 68, 68, 0.78);
background: rgba(245, 158, 11, 0.85);
border: 1px solid rgba(255,255,255,0.20);
color: #fff;
border-radius: 999px;
padding: 1px 6px;
}
.wp-comm-card__body {
padding: 10px;
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 6px;
@@ -259,22 +285,26 @@
}
.wp-comm-card__stats {
display: flex;
gap: 12px;
gap: 10px;
font-size: 11px;
color: var(--wp-text-muted);
margin-top: 2px;
align-items: center;
padding-top: 4px;
border-top: 1px solid var(--wp-border);
}
.wp-comm-card__stats i { margin-right: 4px; }
.wp-comm-card__stats span { display: inline-flex; align-items: center; gap: 3px; }
.wp-comm-card__stats i { margin-right: 3px; }
.wp-comm-card__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: 3px;
}
.wp-comm-card__tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
background: var(--wp-bg-3);
border: 1px solid var(--wp-border);
color: var(--wp-text-muted);
}
.wp-comm-card__installed {
@@ -461,9 +491,12 @@
.wp-comm-review__head {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--wp-text-muted);
flex-wrap: wrap;
}
.wp-comm-review__head strong { color: var(--wp-text); font-size: 13px; }
.wp-comm-review__text { margin: 6px 0 0; font-size: 13px; }
.wp-comm-review__when { margin-left: auto; color: var(--wp-text-dim); font-size: 12px; }
.wp-comm-review__placeholder { margin: 0; font-size: 12px; color: var(--wp-text-muted); font-style: italic; }
.wp-comm-review__text { margin: 8px 0 0; font-size: 13px; line-height: 1.5; }
/* ---------- Stepper ---------- */
.wp-comm-stepper {
+15 -6
View File
@@ -366,20 +366,29 @@ defineExpose({ cellAt, cycleCell });
.wp-matrix-cell[data-mode="exclude"] {
min-width: 36px;
padding: 0 6px;
background: color-mix(in oklab, var(--wp-danger) 16%, transparent);
background: rgba(239, 68, 68, 0.18);
background: color-mix(in oklab, var(--wp-danger) 18%, transparent);
color: var(--wp-danger);
border-color: color-mix(in oklab, var(--wp-danger) 35%, transparent);
border-color: rgba(239, 68, 68, 0.40);
border-color: color-mix(in oklab, var(--wp-danger) 40%, transparent);
}
.wp-matrix-cell[data-mode="boost"] {
background: color-mix(in oklab, var(--wp-success) 16%, transparent);
background: rgba(34, 197, 94, 0.18);
background: color-mix(in oklab, var(--wp-success) 18%, transparent);
color: var(--wp-success);
border-color: color-mix(in oklab, var(--wp-success) 35%, transparent);
border-color: rgba(34, 197, 94, 0.40);
border-color: color-mix(in oklab, var(--wp-success) 40%, transparent);
}
.wp-matrix-cell[data-mode="reduce"] {
background: color-mix(in oklab, var(--wp-warn) 16%, transparent);
background: rgba(245, 158, 11, 0.18);
background: color-mix(in oklab, var(--wp-warn) 18%, transparent);
color: var(--wp-warn);
border-color: color-mix(in oklab, var(--wp-warn) 35%, transparent);
border-color: rgba(245, 158, 11, 0.40);
border-color: color-mix(in oklab, var(--wp-warn) 40%, transparent);
}
.wp-theme-light .wp-matrix-cell[data-mode="exclude"] { color: #991b1b; }
.wp-theme-light .wp-matrix-cell[data-mode="boost"] { color: #166534; }
.wp-theme-light .wp-matrix-cell[data-mode="reduce"] { color: #92400e; }
.wp-matrix-cell__icon {
font-size: 14px;
font-weight: 600;
+180 -76
View File
@@ -2,18 +2,14 @@
/**
* HistoryPanel
*
* Slide-out drawer that lists the last 3 saved snapshots of a module.
* Each entry shows a relative timestamp, the snapshotted name, a JSON
* preview of the snapshot's payload, and a "Restore" button.
* Centered dialog with a master-detail two-pane layout:
* Left (240 px) — clickable list of saved snapshots (newest first).
* Right (1fr) — preview of the selected snapshot: date, name, raw JSON
* and a "Restore this version" primary action.
*
* Click outside / Esc closes (handled by the local overlay click + window
* keydown listener).
*
* The panel is layout-agnostic — it doesn't know what kind of module owns
* the history; the parent editor wires `entries` and decides what to do
* with the `restore` event.
* Click outside / Esc closes. Body scroll is locked while open.
*/
import { computed, onMounted, onBeforeUnmount, watch } from "vue";
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
import Button from "./ui/Button.vue";
import RelativeDate from "./RelativeDate.vue";
import type { ModuleHistoryEntry } from "../api/types";
@@ -33,6 +29,17 @@ const ordered = computed<ModuleHistoryEntry[]>(() =>
[...props.entries].reverse(),
);
const selected = ref(0);
// Reset selection whenever the list changes (e.g., new save while panel is open).
watch(ordered, () => {
selected.value = 0;
});
const currentEntry = computed(() =>
ordered.value[selected.value] ?? null,
);
function close(): void {
emit("update:open", false);
}
@@ -41,6 +48,10 @@ function restore(entry: ModuleHistoryEntry): void {
emit("restore", entry);
}
function itemLabel(idx: number): string {
return idx === 0 ? "Previous save" : `${idx + 1} saves ago`;
}
function previewJson(entry: ModuleHistoryEntry): string {
return JSON.stringify(
{
@@ -55,6 +66,14 @@ function previewJson(entry: ModuleHistoryEntry): string {
);
}
function snapshotDate(entry: ModuleHistoryEntry): string {
try {
return new Date(entry.saved_at).toLocaleString();
} catch {
return entry.saved_at;
}
}
function onKey(e: KeyboardEvent): void {
if (e.key === "Escape" && props.open) close();
}
@@ -66,12 +85,12 @@ onBeforeUnmount(() => {
window.removeEventListener("keydown", onKey);
});
// Keep body from scrolling under the open panel — small UX nicety.
watch(
() => props.open,
(next) => {
if (typeof document === "undefined") return;
document.body.style.overflow = next ? "hidden" : "";
if (next) selected.value = 0;
},
);
</script>
@@ -84,13 +103,14 @@ watch(
data-test="history-overlay"
@mousedown.self="close"
>
<aside
<div
class="wp-history-panel"
role="dialog"
aria-label="Version history"
data-test="history-panel"
>
<header class="wp-history-panel__header">
<!-- Header -->
<div class="wp-history-panel__header">
<div>
<h3 class="wp-history-panel__title">Version history</h3>
<p class="wp-history-panel__sub">
@@ -105,48 +125,69 @@ watch(
data-test="history-close"
@click="close"
/>
</header>
<div v-if="!ordered.length" class="wp-history-empty" data-test="history-empty">
<i class="pi pi-history" />
<p>No previous versions yet.</p>
<p class="wp-history-empty__hint">
Save a change to start tracking history.
</p>
</div>
<!-- Empty state -->
<div
v-if="!ordered.length"
class="wp-history-empty"
data-test="history-empty"
>
<i class="pi pi-history" />
<p>No previous versions yet.</p>
<p class="wp-history-empty__hint">Save a change to start tracking history.</p>
</div>
<!-- Two-pane body -->
<div v-else class="wp-history-panel__body">
<ol class="wp-history-list" data-test="history-list">
<li
<!-- Left: list of snapshots -->
<div class="wp-history-list" data-test="history-list">
<button
v-for="(entry, idx) in ordered"
:key="`${entry.saved_at}-${idx}`"
type="button"
class="wp-history-item"
:data-active="idx === selected ? 'true' : 'false'"
:data-test="`history-entry-${idx}`"
@click="selected = idx"
>
<div class="wp-history-item__head">
<div class="wp-history-item__top">
<span class="wp-history-item__label">{{ itemLabel(idx) }}</span>
<span class="wp-history-item__time">
<RelativeDate :value="entry.saved_at" />
</span>
</div>
<div class="wp-history-item__name">{{ entry.name || "(untitled)" }}</div>
</button>
</div>
<!-- Right: preview pane -->
<div class="wp-history-preview">
<template v-if="currentEntry">
<div class="wp-history-preview__head">
<div>
<div class="wp-history-item__name">
{{ entry.name || "(untitled)" }}
<div class="wp-history-preview__snapshot-label">
Snapshot · {{ snapshotDate(currentEntry) }}
</div>
<div class="wp-history-item__meta">
<RelativeDate :value="entry.saved_at" />
<span class="wp-history-item__sep">·</span>
<span :title="entry.saved_at">{{ entry.saved_at }}</span>
<div class="wp-history-preview__name">
{{ currentEntry.name || "Untitled" }}
</div>
</div>
<Button
icon="pi-refresh"
variant="primary"
size="sm"
:data-test="`history-restore-${idx}`"
@click="restore(entry)"
icon="pi-refresh"
:data-test="`history-restore-${selected}`"
@click="restore(currentEntry)"
>Restore this version</Button>
</div>
<pre class="wp-history-item__json">{{ previewJson(entry) }}</pre>
</li>
</ol>
<pre class="wp-history-preview__json">{{ previewJson(currentEntry) }}</pre>
</template>
<div v-else class="wp-dim wp-history-preview__placeholder">
Select a snapshot to preview.
</div>
</div>
</div>
</aside>
</div>
</div>
</Teleport>
</template>
@@ -156,27 +197,38 @@ watch(
position: fixed;
inset: 0;
z-index: 1100;
background: var(--wp-overlay-bg);
background: rgba(0, 0, 0, 0.55);
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: center;
padding: 24px;
animation: wp-history-fade-in 0.15s ease-out;
}
@keyframes wp-history-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.wp-history-panel {
width: 480px;
max-width: 100vw;
background: var(--wp-bg);
border-left: 1px solid var(--wp-border);
width: min(880px, 100%);
max-height: min(640px, calc(100vh - 48px));
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg, 10px);
display: flex;
flex-direction: column;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
box-shadow: var(--wp-shadow-lg);
}
.wp-history-panel__header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-bg-2);
flex-shrink: 0;
}
.wp-history-panel__title {
margin: 0;
@@ -189,63 +241,115 @@ watch(
font-size: 12px;
color: var(--wp-text-muted);
}
/* Two-pane grid body */
.wp-history-panel__body {
display: grid;
grid-template-columns: 240px 1fr;
flex: 1;
overflow-y: auto;
padding: 12px 16px;
min-height: 0;
}
/* Left column — snapshot list */
.wp-history-list {
list-style: none;
margin: 0;
padding: 0;
border-right: 1px solid var(--wp-border);
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 4px;
}
.wp-history-item {
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
background: var(--wp-bg-2);
width: 100%;
text-align: left;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
padding: 10px 12px;
color: var(--wp-text);
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.wp-history-item__head {
.wp-history-item:hover {
background: var(--wp-bg-3);
}
.wp-history-item[data-active="true"] {
background: rgba(124, 58, 237, 0.20);
background: color-mix(in oklab, var(--wp-accent-600) 20%, transparent);
border-color: rgba(139, 92, 246, 0.50);
border-color: color-mix(in oklab, var(--wp-accent-500) 50%, transparent);
}
.wp-history-item__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
align-items: baseline;
font-size: 11.5px;
color: var(--wp-text-muted);
margin-bottom: 4px;
}
.wp-history-item__label {
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.wp-history-item__time {
font-family: var(--wp-font-mono, ui-monospace, monospace);
}
.wp-history-item__name {
font-size: 13px;
font-weight: 600;
color: var(--wp-text);
word-break: break-word;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wp-history-item__meta {
/* Right column — preview pane */
.wp-history-preview {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.wp-history-preview__head {
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
gap: 12px;
flex-shrink: 0;
}
.wp-history-preview__snapshot-label {
font-size: 11px;
color: var(--wp-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wp-history-preview__name {
font-size: 14px;
font-weight: 600;
margin-top: 2px;
color: var(--wp-text);
}
.wp-history-item__sep {
color: var(--wp-text-dim, #6e6e7c);
}
.wp-history-item__json {
.wp-history-preview__json {
flex: 1;
margin: 0;
max-height: 240px;
padding: 14px 16px;
overflow: auto;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11.5px;
line-height: 1.4;
color: var(--wp-text-muted);
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: 4px;
padding: 8px 10px;
color: var(--wp-text);
white-space: pre;
white-space: pre-wrap;
word-break: break-word;
}
.wp-history-preview__placeholder {
padding: 24px;
font-size: 13px;
}
/* Empty state */
.wp-history-empty {
flex: 1;
display: flex;
+1 -1
View File
@@ -53,7 +53,7 @@ const categoryStore = useCategoryStore();
const categoryOptions = computed<SelectOption[]>(() => [
{ value: null, label: "None" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
// `varBinding` auto-tracks slugified name until the user touches it.
+22 -17
View File
@@ -364,8 +364,9 @@ onBeforeUnmount(() => {
@mousedown="(e) => onSuggestionMouseDown(e, label)"
@mouseenter="acActive = i"
>
<i :class="acTrigger === '$' ? 'pi pi-dollar' : 'pi pi-at'" class="wp-rt-suggestions__icon" />
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ label }}
<span class="wp-rt-suggestions__label">
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ label }}
</span>
</button>
</div>
</Teleport>
@@ -476,15 +477,15 @@ onBeforeUnmount(() => {
* ------------------------------------------------------------------------ */
.wp-rt-suggestions {
position: fixed;
z-index: 1000;
z-index: 9999;
min-width: 200px;
max-width: 360px;
max-height: 240px;
overflow-y: auto;
background: var(--wp-bg-1, #11111b);
background: var(--wp-bg-2, #15151f);
border: 1px solid var(--wp-border-strong, rgba(255, 255, 255, 0.14));
border-radius: 8px;
padding: 4px;
padding: 0 4px 4px;
box-shadow: var(--wp-shadow-lg, var(--wp-shadow, 0 10px 30px rgba(0, 0, 0, 0.45)));
display: flex;
flex-direction: column;
@@ -494,7 +495,6 @@ onBeforeUnmount(() => {
animation: wp-rt-suggestions-in 0.12s ease-out;
}
.wp-rt-suggestions--up {
/* `top` is set inline as the rect.top of the input; shift fully above. */
transform: translateY(-100%);
animation-name: wp-rt-suggestions-in-up;
}
@@ -508,12 +508,15 @@ onBeforeUnmount(() => {
}
.wp-rt-suggestions__head {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
padding: 6px 10px;
font-size: 11px;
color: var(--wp-text-muted, #a1a1ad);
color: var(--wp-text-dim, #6e6e7c);
background: var(--wp-bg-3, #1e1e2a);
border-bottom: 1px solid var(--wp-border, rgba(255, 255, 255, 0.08));
margin-bottom: 2px;
margin: 0 -4px 4px;
border-radius: 7px 7px 0 0;
}
.wp-rt-suggestions__query {
font-family: var(--wp-font-mono, ui-monospace, monospace);
@@ -521,30 +524,32 @@ onBeforeUnmount(() => {
}
.wp-rt-suggestions__hint {
margin-left: auto;
opacity: 0.7;
opacity: 0.6;
font-family: var(--wp-font, system-ui, sans-serif);
}
.wp-rt-suggestions__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-radius: 4px;
padding: 6px 8px;
padding: 7px 10px;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
font-size: 12.5px;
color: var(--wp-text, #e7e7ee);
cursor: pointer;
}
.wp-rt-suggestions__item[data-active] {
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 22%, transparent);
}
.wp-rt-suggestions__icon {
font-size: 10px;
color: var(--wp-accent-text, #c4b5fd);
opacity: 0.85;
.wp-rt-suggestions__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wp-rt-suggestions__trigger {
color: var(--wp-accent-text, #c4b5fd);
+2 -2
View File
@@ -32,8 +32,8 @@ const colorStyle = computed(() => {
const c = props.color;
return {
color: c,
background: `color-mix(in oklab, ${c} 14%, transparent)`,
borderColor: `color-mix(in oklab, ${c} 35%, transparent)`,
background: `color-mix(in oklab, ${c} 18%, transparent)`,
borderColor: `color-mix(in oklab, ${c} 42%, transparent)`,
};
});
+87 -34
View File
@@ -5,6 +5,8 @@ import Icon from "./Icon.vue";
export interface SelectOption {
value: string | number | null;
label: string;
/** Optional color dot shown before the label (e.g. category color). */
dot?: string;
}
interface Props {
@@ -29,6 +31,9 @@ const open = ref(false);
const flip = ref(false);
const active = ref(0);
/** Fixed-position coordinates for the teleported menu. */
const menuStyle = ref<Record<string, string>>({});
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
const btnClasses = computed(() => [
@@ -37,28 +42,66 @@ const btnClasses = computed(() => [
]);
function onDocClick(e: MouseEvent) {
if (!wrapRef.value) return;
if (!wrapRef.value.contains(e.target as Node)) open.value = false;
const t = e.target as Node | null;
if (!t) return;
// Close when click is outside the trigger button AND outside the menu.
if (wrapRef.value?.contains(t)) return;
open.value = false;
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
document.addEventListener("mousedown", onDocClick, true);
});
onBeforeUnmount(() => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("mousedown", onDocClick, true);
window.removeEventListener("scroll", closeOnScroll, true);
window.removeEventListener("resize", reposition);
});
watch(open, (v) => {
if (!v || !btnRef.value) return;
// Decide if the menu should flip above the button.
function closeOnScroll() { open.value = false; }
function reposition() {
if (!open.value || !btnRef.value) return;
computeMenuStyle();
}
function computeMenuStyle() {
if (!btnRef.value) return;
const rect = btnRef.value.getBoundingClientRect();
const need = Math.min(244, props.options.length * 36 + 12);
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;
menuStyle.value = flip.value
? {
position: "fixed",
bottom: (window.innerHeight - rect.top + 4) + "px",
top: "auto",
left: rect.left + "px",
minWidth: rect.width + "px",
}
: {
position: "fixed",
top: (rect.bottom + 4) + "px",
bottom: "auto",
left: rect.left + "px",
minWidth: rect.width + "px",
};
}
watch(open, (v) => {
if (!v) {
window.removeEventListener("scroll", closeOnScroll, true);
window.removeEventListener("resize", reposition);
return;
}
if (!btnRef.value) return;
computeMenuStyle();
// Highlight currently-selected option (or first).
const idx = props.options.findIndex((o) => o.value === props.modelValue);
active.value = idx >= 0 ? idx : 0;
window.addEventListener("scroll", closeOnScroll, true);
window.addEventListener("resize", reposition);
});
function toggle() {
@@ -119,9 +162,12 @@ function onKeydown(e: KeyboardEvent) {
@click="toggle"
@keydown="onKeydown"
>
<span v-if="selected">{{ selected.label }}</span>
<span v-else class="wp-select__placeholder">{{ placeholder }}</span>
<span class="wp-spacer" />
<!-- Selected label — optional color dot prefix. -->
<span class="wp-select__label-wrap">
<span v-if="selected?.dot" class="wp-select__dot" :style="{ background: selected.dot }" />
<span v-if="selected" class="wp-select__label-text">{{ selected.label }}</span>
<span v-else class="wp-select__placeholder">{{ placeholder }}</span>
</span>
<button
v-if="clearable && selected"
type="button"
@@ -133,29 +179,36 @@ function onKeydown(e: KeyboardEvent) {
</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"
<!-- Menu teleported to <body> so it escapes any overflow:hidden ancestor
and renders above the sticky footer regardless of z-index stacking contexts. -->
<Teleport to="body">
<ul
v-if="open"
class="wp-select__menu"
role="listbox"
:style="menuStyle"
data-test="select-menu"
>
{{ opt.label }}
<span class="wp-spacer" />
<Icon v-if="opt.value === modelValue" name="check" :size="11" />
</li>
</ul>
<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"
>
<span v-if="opt.dot" class="wp-select__dot" :style="{ background: opt.dot }" />
<span class="wp-select__option-label">{{ opt.label }}</span>
<span class="wp-spacer" />
<Icon v-if="opt.value === modelValue" name="check" :size="11" />
</li>
</ul>
</Teleport>
</div>
</template>
+31 -4
View File
@@ -35,7 +35,10 @@
.wp-rt .wp-rt-var,
.wp-rt-preview .wp-rt-var {
color: var(--wp-accent-text-strong, var(--wp-accent-text, #c4b5fd));
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 15%, transparent);
background: rgba(139, 92, 246, 0.16);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent);
box-shadow: inset 0 0 0 1px rgba(139, 92, 246, 0.32);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
border-radius: 4px;
font-weight: 500;
}
@@ -43,7 +46,10 @@
.wp-rt .wp-rt-ref,
.wp-rt-preview .wp-rt-ref {
color: var(--wp-kind-wildcard, #f0abfc);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 12%, transparent);
background: rgba(192, 38, 211, 0.15);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 15%, transparent);
box-shadow: inset 0 0 0 1px rgba(192, 38, 211, 0.30);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 30%, transparent);
border-radius: 4px;
font-weight: 500;
}
@@ -73,7 +79,8 @@
.wp-rt .wp-rt-quantifier,
.wp-rt-preview .wp-rt-quantifier {
color: var(--wp-info, #60a5fa);
background: color-mix(in oklab, var(--wp-info, #3b82f6) 14%, transparent);
background: rgba(59, 130, 246, 0.16);
background: color-mix(in oklab, var(--wp-info, #3b82f6) 16%, transparent);
border-radius: 3px;
font-weight: 500;
}
@@ -86,17 +93,18 @@
.wp-rt--focused .wp-rt-var,
.wp-rt--focused .wp-rt-ref {
padding: 0;
box-shadow: inset 0 0 0 1px var(--wp-accent-500, #8b5cf6);
}
.wp-rt--rest .wp-rt-var,
.wp-rt--rest .wp-rt-ref {
padding: 0 4px;
margin: 0 0.5px;
border-radius: 4px;
}
.wp-rt--rest .wp-rt-dp-multi,
.wp-rt--rest .wp-rt-dp-weight,
.wp-rt--rest .wp-rt-quantifier {
padding: 0 3px;
margin: 0 0.5px;
}
/* Read-only preview always renders chips at rest. Match the
@@ -111,3 +119,22 @@
.wp-rt-preview .wp-rt-quantifier {
padding: 0 3px;
}
/* ── Light-mode overrides: rich-text token chrome ── */
.wp-theme-light .wp-rt .wp-rt-var,
.wp-theme-light .wp-rt-preview .wp-rt-var {
/* color auto-adapts (--wp-accent-text-strong → accent-800 in light mode) */
background: rgba(139, 92, 246, 0.13);
box-shadow: inset 0 0 0 1px rgba(139, 92, 246, 0.36);
}
.wp-theme-light .wp-rt .wp-rt-ref,
.wp-theme-light .wp-rt-preview .wp-rt-ref {
color: #86198f;
background: rgba(192, 38, 211, 0.13);
box-shadow: inset 0 0 0 1px rgba(192, 38, 211, 0.36);
}
.wp-theme-light .wp-rt .wp-rt-quantifier,
.wp-theme-light .wp-rt-preview .wp-rt-quantifier {
color: #1d4ed8;
background: rgba(59, 130, 246, 0.13);
}
+123 -27
View File
@@ -452,6 +452,17 @@ input, textarea, select { font: inherit; }
}
.wp-input::placeholder, .wp-textarea::placeholder { color: var(--wp-text-dim); }
.wp-input--sm, .wp-select--sm { height: var(--wp-input-h-sm); font-size: 12px; padding: 0 8px; }
/* Custom select trigger button — flex layout so label + spacer + chevron align. */
.wp-select {
display: flex;
align-items: center;
gap: 4px;
text-align: left;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
box-sizing: border-box;
}
.wp-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.wp-field__label {
@@ -520,13 +531,16 @@ input, textarea, select { font: inherit; }
}
.wp-chip--accent {
color: var(--wp-accent-text-strong);
background: color-mix(in oklab, var(--wp-accent-500) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 35%, transparent);
background: rgba(139, 92, 246, 0.16);
background: color-mix(in oklab, var(--wp-accent-500) 16%, transparent);
border-color: rgba(139, 92, 246, 0.38);
border-color: color-mix(in oklab, var(--wp-accent-500) 38%, transparent);
}
.wp-chip--success { color: #86efac; background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.32); }
.wp-chip--warn { color: #fcd34d; background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.32); }
.wp-chip--danger { color: #fca5a5; background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.32); }
.wp-chip--info { color: #93c5fd; background: rgba(59,130,246,0.12); border-color: rgba(59,130,246,0.32); }
/* dark-mode text is intentionally light; light-mode overrides below */
.wp-chip--success { color: #86efac; background: rgba(34,197,94,0.14); border-color: rgba(34,197,94,0.36); }
.wp-chip--warn { color: #fcd34d; background: rgba(245,158,11,0.14); border-color: rgba(245,158,11,0.36); }
.wp-chip--danger { color: #fca5a5; background: rgba(239,68,68,0.14); border-color: rgba(239,68,68,0.36); }
.wp-chip--info { color: #93c5fd; background: rgba(59,130,246,0.14); border-color: rgba(59,130,246,0.36); }
.wp-chip__dot { width: 7px; height: 7px; border-radius: 999px; background: currentColor; }
.wp-chip__close {
background: none; border: none; padding: 0; margin-left: 2px;
@@ -602,6 +616,15 @@ input, textarea, select { font: inherit; }
transition: all 0.12s ease;
}
.wp-tag-chip:hover { background: var(--wp-bg-4); color: var(--wp-text); }
/* Active state defined globally so scoped view files can use color-mix and this
serves as the rgba fallback when color-mix is unsupported (old Chromium). */
.wp-tag-chip[data-active="true"] {
background: rgba(139, 92, 246, 0.22);
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: rgba(139, 92, 246, 0.45);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-id {
font-family: var(--wp-font-mono);
@@ -690,20 +713,67 @@ input, textarea, select { font: inherit; }
.wp-syntax-pill--ref {
/* outgoing nested @refs — pink/magenta to match @ref token color */
color: #f0abfc;
background: color-mix(in oklab, #c026d3 14%, transparent);
border-color: color-mix(in oklab, #c026d3 28%, transparent);
background: rgba(192, 38, 211, 0.18);
background: color-mix(in oklab, #c026d3 18%, transparent);
border-color: rgba(192, 38, 211, 0.32);
border-color: color-mix(in oklab, #c026d3 32%, transparent);
}
.wp-syntax-pill--in {
/* incoming references — accent (purple) */
color: var(--wp-accent-text);
background: color-mix(in oklab, var(--wp-accent-500) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 32%, transparent);
background: rgba(139, 92, 246, 0.18);
background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
border-color: rgba(139, 92, 246, 0.35);
border-color: color-mix(in oklab, var(--wp-accent-500) 35%, transparent);
}
.wp-syntax-pill--dp {
/* inline {a|b|c} — yellow to match dp-brace token color */
color: #fcd34d;
background: color-mix(in oklab, #facc15 14%, transparent);
border-color: color-mix(in oklab, #facc15 30%, transparent);
background: rgba(250, 204, 21, 0.18);
background: color-mix(in oklab, #facc15 18%, transparent);
border-color: rgba(250, 204, 21, 0.32);
border-color: color-mix(in oklab, #facc15 32%, transparent);
}
/* Light-mode pill overrides — dark text + stronger tint for contrast on near-white surface */
.wp-theme-light .wp-syntax-pill--ref {
color: #86198f;
background: rgba(192, 38, 211, 0.13);
border-color: rgba(192, 38, 211, 0.38);
}
.wp-theme-light .wp-syntax-pill--in {
/* color inherits --wp-accent-text which light mode sets to accent-700 (#6d28d9) */
background: rgba(139, 92, 246, 0.13);
border-color: rgba(139, 92, 246, 0.40);
}
.wp-theme-light .wp-syntax-pill--dp {
color: #92400e;
background: rgba(234, 179, 8, 0.15);
border-color: rgba(234, 179, 8, 0.45);
}
/* ── Light-mode overrides: chips, tags, links ── */
/* Tone chips: swap light text for dark text, slightly stronger tint */
.wp-theme-light .wp-chip--accent {
/* color inherits --wp-accent-text-strong → accent-800 in light mode */
background: rgba(139, 92, 246, 0.13);
border-color: rgba(139, 92, 246, 0.40);
}
.wp-theme-light .wp-chip--success { color: #166534; background: rgba(34,197,94,0.14); border-color: rgba(34,197,94,0.42); }
.wp-theme-light .wp-chip--warn { color: #92400e; background: rgba(245,158,11,0.14); border-color: rgba(245,158,11,0.42); }
.wp-theme-light .wp-chip--danger { color: #991b1b; background: rgba(239,68,68,0.14); border-color: rgba(239,68,68,0.42); }
.wp-theme-light .wp-chip--info { color: #1e40af; background: rgba(59,130,246,0.14); border-color: rgba(59,130,246,0.42); }
/* Tag chip active: color auto-adapts (--wp-accent-text → dark purple in light mode) */
.wp-theme-light .wp-tag-chip[data-active="true"] {
background: rgba(139, 92, 246, 0.16);
border-color: rgba(139, 92, 246, 0.42);
}
/* Missing wildcard ref link */
.wp-theme-light .wp-syntax-link--missing,
.wp-theme-light .wp-syntax-link--missing:hover {
color: #b91c1c;
border-color: rgba(239, 68, 68, 0.42);
background: rgba(239, 68, 68, 0.12);
}
/* Reference graph block inside row-expand */
@@ -736,20 +806,26 @@ input, textarea, select { font: inherit; }
}
.wp-syntax-link:hover {
color: var(--wp-accent-text);
border-color: rgba(139, 92, 246, 0.45);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
background: rgba(139, 92, 246, 0.12);
background: color-mix(in oklab, var(--wp-accent-500) 12%, var(--wp-bg-3));
}
.wp-syntax-link--missing {
color: #f87171;
border-color: color-mix(in oklab, #f87171 35%, transparent);
background: color-mix(in oklab, #f87171 12%, transparent);
border-color: rgba(248, 113, 113, 0.38);
border-color: color-mix(in oklab, #f87171 38%, transparent);
background: rgba(248, 113, 113, 0.14);
background: color-mix(in oklab, #f87171 14%, transparent);
font-family: var(--wp-font-mono);
cursor: default;
}
.wp-syntax-link--missing:hover {
color: #f87171;
border-color: color-mix(in oklab, #f87171 35%, transparent);
background: color-mix(in oklab, #f87171 12%, transparent);
border-color: rgba(248, 113, 113, 0.38);
border-color: color-mix(in oklab, #f87171 38%, transparent);
background: rgba(248, 113, 113, 0.14);
background: color-mix(in oklab, #f87171 14%, transparent);
}
.wp-id {
@@ -1353,25 +1429,20 @@ input, textarea, select { font: inherit; }
/* ---------- Custom select popup ---------- */
.wp-select-wrap { position: relative; display: inline-block; width: 100%; }
/* Menu teleported to <body>; position/top/left provided via inline style from JS. */
.wp-select__menu {
position: absolute;
left: 0; right: 0;
top: calc(100% + 4px);
background: var(--wp-bg-3);
border: 1px solid var(--wp-border-strong);
border-radius: 7px;
box-shadow: var(--wp-shadow-lg);
z-index: 50;
z-index: 9999;
padding: 4px;
max-height: 240px;
overflow: auto;
list-style: none;
margin: 0;
}
.wp-select__menu[data-flip="true"] {
top: auto;
bottom: calc(100% + 4px);
}
.wp-select__option {
display: flex; align-items: center; gap: 6px;
padding: 6px 8px;
@@ -1386,15 +1457,40 @@ input, textarea, select { font: inherit; }
color: var(--wp-text);
}
.wp-select__option[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
background: rgba(139, 92, 246, 0.20);
background: color-mix(in oklab, var(--wp-accent-500) 20%, transparent);
color: var(--wp-text);
}
.wp-select__option[data-selected="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 16%, transparent);
background: rgba(139, 92, 246, 0.18);
background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
color: var(--wp-text);
}
/* Dot + label wrapper inside trigger button */
.wp-select__label-wrap {
display: flex; align-items: center; gap: 6px;
flex: 1; min-width: 0; overflow: hidden;
}
/* Color dot — rendered in both trigger and option list */
.wp-select__dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
/* Label text inside trigger — must be a flex item (not inline) for ellipsis to work */
.wp-select__label-text,
.wp-select__placeholder {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Option label text — no truncation; menu grows to fit widest option */
.wp-select__option-label {
flex: 1;
white-space: nowrap;
}
.wp-select__placeholder { color: var(--wp-text-dim); }
.wp-select__chevron { color: var(--wp-text-dim); font-size: 11px; margin-left: 6px; }
.wp-select__chevron { color: var(--wp-text-dim); font-size: 11px; margin-left: 6px; flex-shrink: 0; }
/* ---------- Color picker (categories) ---------- */
.wp-color-picker {
+2 -2
View File
@@ -17,7 +17,7 @@ export function catChipStyle(color?: string | null): Record<string, string> {
}
return {
color,
background: `color-mix(in oklab, ${color} 14%, transparent)`,
borderColor: `color-mix(in oklab, ${color} 35%, transparent)`,
background: `color-mix(in oklab, ${color} 18%, transparent)`,
borderColor: `color-mix(in oklab, ${color} 42%, transparent)`,
};
}
+1 -1
View File
@@ -29,7 +29,7 @@ const allTags = computed(() => {
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
onMounted(async () => {
+1 -1
View File
@@ -43,7 +43,7 @@ const allTags = computed(() => {
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
onMounted(async () => {
+3 -3
View File
@@ -36,7 +36,7 @@ const allTags = computed(() => {
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
onMounted(async () => {
@@ -280,9 +280,9 @@ function actView(a: DerivationAction | undefined): { verb: string; target: strin
.wp-rules-snippet {
max-width: 720px;
line-height: 1.7;
line-height: 1.5;
}
.wp-rule-block { margin-bottom: 8px; }
.wp-rule-block { margin-bottom: 6px; }
.wp-rule-block:last-child { margin-bottom: 0; }
.wp-rule-block__head {
font-size: 10.5px;
+1 -1
View File
@@ -29,7 +29,7 @@ const allTags = computed(() => {
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
onMounted(async () => {
+3 -12
View File
@@ -8,6 +8,7 @@ import Icon from "../components/ui/Icon.vue";
import Input from "../components/ui/Input.vue";
import Select from "../components/ui/Select.vue";
import { useToast } from "../composables/useToast";
import { catChipStyle } from "../utils/catChip";
import { api } from "../api/client";
import type { CategoryRow, ImportBundle, ModuleRow } from "../api/types";
import {
@@ -609,8 +610,8 @@ watch(
<span class="wp-io-row__name">{{ row.name }}</span>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="wp-io-row__catchip"
:style="{ background: categoryById.get(row.category_id)?.color || 'var(--wp-bg-3)' }"
class="wp-cat-chip"
:style="catChipStyle(categoryById.get(row.category_id)!.color)"
>{{ categoryById.get(row.category_id)?.name }}</span>
<span class="wp-id">{{ row.id }}</span>
</div>
@@ -890,16 +891,6 @@ watch(
}
.wp-io-row:hover { background: var(--wp-bg-2); }
.wp-io-row__name { flex: 1; font-weight: 500; }
.wp-io-row__catchip {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
color: #fff;
text-shadow: 0 1px 0 rgba(0,0,0,.3);
white-space: nowrap;
}
.wp-io-row__empty {
padding: 10px 12px 10px 36px;
font-size: 12px;
+1 -1
View File
@@ -41,7 +41,7 @@ const allTags = computed(() => {
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
onMounted(async () => {
+31 -8
View File
@@ -35,7 +35,7 @@ const allTags = computed(() => {
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
...categoryStore.items.map((c) => ({ value: c.id, label: c.name, dot: c.color || undefined })),
]);
// Reactive ref-graph computed off the store. Rebuilds on every change since
@@ -376,19 +376,42 @@ function isValid(row: ModuleRow): boolean {
}
.wp-syntax-pill .pi { font-size: 9.5px; }
.wp-syntax-pill--ref {
color: var(--wp-kind-wildcard, #f0abfc);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 28%, transparent);
/* @ref token color — pink/magenta, intentionally distinct from wildcard KIND violet */
color: #f0abfc;
background: rgba(192, 38, 211, 0.18);
background: color-mix(in oklab, #c026d3 18%, transparent);
border-color: rgba(192, 38, 211, 0.32);
border-color: color-mix(in oklab, #c026d3 32%, transparent);
}
.wp-syntax-pill--in {
color: var(--wp-accent-text, #c4b5fd);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
background: rgba(139, 92, 246, 0.18);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 18%, transparent);
border-color: rgba(139, 92, 246, 0.35);
border-color: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 35%, transparent);
}
.wp-syntax-pill--dp {
color: var(--wp-warn, #fcd34d);
background: color-mix(in oklab, var(--wp-warn, #facc15) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-warn, #facc15) 30%, transparent);
background: rgba(250, 204, 21, 0.18);
background: color-mix(in oklab, var(--wp-warn, #facc15) 18%, transparent);
border-color: rgba(250, 204, 21, 0.32);
border-color: color-mix(in oklab, var(--wp-warn, #facc15) 32%, transparent);
}
/* Light-mode overrides — must repeat here because scoped [data-v-xxx] wins specificity */
.wp-theme-light .wp-syntax-pill--ref {
color: #86198f;
background: rgba(192, 38, 211, 0.13);
border-color: rgba(192, 38, 211, 0.38);
}
.wp-theme-light .wp-syntax-pill--in {
background: rgba(139, 92, 246, 0.13);
border-color: rgba(139, 92, 246, 0.40);
}
.wp-theme-light .wp-syntax-pill--dp {
color: #92400e;
background: rgba(234, 179, 8, 0.15);
border-color: rgba(234, 179, 8, 0.45);
}
.wp-opts-grid {
@@ -10,7 +10,6 @@ import { useRouter } from "vue-router";
import Button from "../../components/ui/Button.vue";
import Input from "../../components/ui/Input.vue";
import Select from "../../components/ui/Select.vue";
import Checkbox from "../../components/ui/Checkbox.vue";
import { useCommunityStore } from "../../stores/communityStore";
import CommunityCard from "../../community/CommunityCard.vue";
import { KIND_ICON, KIND_LABEL, fmtNumber } from "../../community/format";
@@ -132,7 +131,7 @@ onMounted(async () => {
@click="openDetail(atom.id)"
>
<div class="wp-comm-feat__inner">
<span class="wp-comm-card__kind">
<span class="wp-comm-feat__kind">
<i :class="['pi', KIND_ICON[atom.type === 'bundle' ? 'bundle' : atom.type]]" aria-hidden="true" />
{{ KIND_LABEL[atom.type === "bundle" ? "bundle" : atom.type] }}
</span>
@@ -174,15 +173,45 @@ onMounted(async () => {
aria-label="Sort modules"
/>
</div>
<label class="wp-comm-filters__toggle">
<Checkbox v-model="verifiedOnly" /> Verified only
</label>
<label class="wp-comm-filters__toggle">
<Checkbox v-model="compatibleOnly" /> Compatible (engine {{ store.engineVersion }})
</label>
<label class="wp-comm-filters__toggle wp-comm-filters__toggle--nsfw">
<Checkbox v-model="includeNsfw" /> Show 18+
</label>
<button
type="button"
class="wp-comm-filters__toggle"
:data-active="verifiedOnly ? 'true' : 'false'"
@click="verifiedOnly = !verifiedOnly"
>
<span class="wp-check" :data-checked="verifiedOnly ? 'true' : 'false'" aria-hidden="true">
<svg v-if="verifiedOnly" viewBox="0 0 12 12" fill="none" 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>
</span>
Verified only
</button>
<button
type="button"
class="wp-comm-filters__toggle"
:data-active="compatibleOnly ? 'true' : 'false'"
@click="compatibleOnly = !compatibleOnly"
>
<span class="wp-check" :data-checked="compatibleOnly ? 'true' : 'false'" aria-hidden="true">
<svg v-if="compatibleOnly" viewBox="0 0 12 12" fill="none" 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>
</span>
Compatible (engine {{ store.engineVersion }})
</button>
<button
type="button"
class="wp-comm-filters__toggle wp-comm-filters__toggle--nsfw"
:data-active="includeNsfw ? 'true' : 'false'"
@click="includeNsfw = !includeNsfw"
>
<span class="wp-check" :data-checked="includeNsfw ? 'true' : 'false'" aria-hidden="true">
<svg v-if="includeNsfw" viewBox="0 0 12 12" fill="none" 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>
</span>
Show 18+
</button>
</section>
<!-- Tag rail -->
@@ -232,12 +261,26 @@ onMounted(async () => {
<style scoped>
@import "../../community/community.css";
/* Filter-bar select widths — replaces ad-hoc `w-40` / `w-44` utility leaks
with semantic names tied to the filter being constrained. */
/* Filter-bar select widths. */
.wp-comm-filter-select--kind { width: 160px; }
.wp-comm-filter-select--sort { width: 180px; }
/* Empty-state CTA spacer — replaces the `mt-3` utility leak. */
/* Toggle buttons — pill-shaped, cursor pointer, match prototype's wp-comm-toggle. */
.wp-comm-filters__toggle {
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
border: 1px solid transparent;
}
.wp-comm-filters__toggle:hover {
background: var(--wp-bg-4);
}
.wp-comm-filters__toggle[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 16%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 32%, transparent);
color: var(--wp-accent-text);
}
/* Empty-state CTA spacer. */
.wp-empty__cta { margin-top: 12px; }
/* Loading skeleton fallback for the card hero gradient. */
@@ -11,7 +11,6 @@ import Button from "../../components/ui/Button.vue";
import Input from "../../components/ui/Input.vue";
import Textarea from "../../components/ui/Textarea.vue";
import Select from "../../components/ui/Select.vue";
import Checkbox from "../../components/ui/Checkbox.vue";
import { useToast } from "../../composables/useToast";
import { useCommunityStore } from "../../stores/communityStore";
import { KIND_LABEL } from "../../community/format";
@@ -259,9 +258,19 @@ function onTagKey(event: KeyboardEvent) {
/>
</div>
<div class="form-row">
<label class="wp-comm-filters__toggle">
<Checkbox v-model="form.nsfw" /> Mark as 18+ (NSFW)
</label>
<button
type="button"
class="wp-comm-filters__toggle"
:data-active="form.nsfw ? 'true' : 'false'"
@click="form.nsfw = !form.nsfw"
>
<span class="wp-check" :data-checked="form.nsfw ? 'true' : 'false'" aria-hidden="true">
<svg v-if="form.nsfw" viewBox="0 0 12 12" fill="none" 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>
</span>
Mark as 18+ (NSFW)
</button>
</div>
<div class="form-actions form-actions--split">
<Button variant="secondary" icon="arrow-left" @click="step = 1">Back</Button>
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-BWnFunYr.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-C6od6CgI.css">
<script type="module" crossorigin src="/wp/assets/manager-Bs_mFjO4.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CCg9HOCG.css">
</head>
<body>
<div id="app"></div>