Compare commits

...
15 changed files with 324 additions and 647 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
"announce": "node scripts/discord-announce.mjs"
},
"dependencies": {
"@dumiflex/wp-kit": "^0.17.0",
"@dumiflex/wp-kit": "^0.18.0",
"pinia": "^2.2.6",
"primeicons": "^8.0.2",
"vue": "^3.5.44",
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@dumiflex/wp-kit':
specifier: ^0.17.0
version: 0.17.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))
specifier: ^0.18.0
version: 0.18.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))
pinia:
specifier: ^2.2.6
version: 2.3.1(typescript@5.9.3)(vue@3.5.44(typescript@5.9.3))
@@ -287,8 +287,8 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dumiflex/wp-kit@0.17.0':
resolution: {integrity: sha512-7DS9FWvdS3H3MqZi48dl5+ry/7dcVCeUckUvrwqba/VNCNd+SsJ8z2ioRWGGRKMZSA912aYQBLEMkcYbXNLIrA==}
'@dumiflex/wp-kit@0.18.0':
resolution: {integrity: sha512-md2NF4dJJIUKXSpgUnXzzvwhznq6hYspyfjRC2T51ifBrqqfyFd5zNMN/K5gdNK6eepO74ugK0Dgt+iFTBPEHA==}
engines: {node: '>=22'}
peerDependencies:
primeicons: ^7.0.0 || ^8.0.0
@@ -2436,7 +2436,7 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dumiflex/wp-kit@0.17.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))':
'@dumiflex/wp-kit@0.18.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))':
dependencies:
primeicons: 8.0.2
vue: 3.5.44(typescript@5.9.3)
+21 -52
View File
@@ -13,7 +13,7 @@ import { computed, nextTick, ref, watch } from "vue";
import WpSelect from "@/kit/WpSelect.vue";
import { api } from "../../manager/api/client";
import type { TemplateRow } from "../../manager/api/types";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Skeleton from "@/kit/Skeleton.vue";
import Button from "@/kit/Button.vue";
import Chip from "@/kit/Chip.vue";
@@ -128,19 +128,17 @@ function pick(row: TemplateRow) {
</script>
<template>
<ModalShell :visible="open" labelledby="wp-ltm-title" @close="emit('close')">
<div class="wp-ltm" @click.stop>
<header class="wp-ltm__head">
<div class="wp-ltm__head-text">
<div class="wp-ltm__eyebrow">Load template from library</div>
<div id="wp-ltm-title" class="wp-ltm__title">
Browse Templates
<span class="wp-ltm__title-detail">· {{ rows.length }} {{ rows.length === 1 ? "template" : "templates" }}</span>
</div>
<Modal :open="open" size="lg" flush accent="brand" icon="folder-open" @update:open="(v: boolean) => { if (!v) emit('close'); }">
<template #header="{ titleId }">
<div class="wp-ltm__head-text">
<div class="wp-ltm__eyebrow">Load template from library</div>
<div :id="titleId" class="wp-ltm__title">
Browse Templates
<span class="wp-ltm__title-detail">· {{ rows.length }} {{ rows.length === 1 ? "template" : "templates" }}</span>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="load-tpl-close" @click="emit('close')" />
</header>
</div>
</template>
<div class="wp-ltm">
<div class="wp-ltm__toolbar">
<SearchInput
ref="searchInput"
@@ -269,49 +267,26 @@ function pick(row: TemplateRow) {
</template>
</div>
<footer class="wp-ltm__foot">
<span class="wp-ltm__hint">Click a template to load it into the assembler.</span>
<span class="wp-ltm__foot-spacer" />
<Button size="sm" data-test="load-tpl-cancel" @click="emit('close')">Cancel</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<span class="wp-ltm__hint">Click a template to load it into the assembler.</span>
<span class="wp-ltm__foot-spacer" />
<Button size="sm" data-test="load-tpl-cancel" @click="emit('close')">Cancel</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-ltm {
background: var(--wp-bg-2);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 12px);
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.55));
width: 640px;
max-width: calc(100vw - 32px);
height: 560px;
max-height: calc(100vh - 32px);
height: 480px;
max-height: calc(100vh - 160px);
display: flex;
flex-direction: column;
color: var(--wp-text);
overflow: hidden;
font-family: var(--wp-font-sans, sans-serif);
}
/* Header — brand wash mirrors ModulePickerModal. */
.wp-ltm__head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient, transparent);
position: relative;
}
.wp-ltm__head::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-ltm__head > * { position: relative; z-index: 1; }
.wp-ltm__head-text { flex: 1; min-width: 0; }
.wp-ltm__head-text { flex: 1 1 auto; min-width: 0; }
.wp-ltm__eyebrow {
font-size: 11px;
text-transform: uppercase;
@@ -453,12 +428,6 @@ function pick(row: TemplateRow) {
}
/* Footer */
.wp-ltm__foot {
height: 44px; padding: 0 16px;
display: flex; align-items: center; gap: 10px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
color: var(--wp-text-dim); font-size: 11px;
}
.wp-ltm__foot-spacer { flex: 1; }
.wp-ltm__hint { align-self: center; color: var(--wp-text-dim); font-size: 11px; }
</style>
+23 -65
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../../manager/api/client";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Textarea from "@/kit/Textarea.vue";
import Input from "@/kit/Input.vue";
import WpSelect from "@/kit/WpSelect.vue";
@@ -100,12 +100,8 @@ async function submit(mode: "update" | "new") {
</script>
<template>
<ModalShell :visible="open" labelledby="wp-stm-title" @close="emit('close')">
<div class="wp-stm" @click.stop>
<header class="wp-stm__head">
<h3 id="wp-stm-title" class="wp-stm__title">Save template</h3>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="save-tpl-close" @click="emit('close')" />
</header>
<Modal :open="open" title="Save template" size="md" flush accent="brand" icon="save" @update:open="(v: boolean) => { if (!v) emit('close'); }">
<div class="wp-stm">
<div class="wp-stm__body">
<div class="wp-stm-field">
@@ -182,60 +178,30 @@ async function submit(mode: "update" | "new") {
<p v-if="errorMsg" class="wp-stm-error" data-test="save-tpl-error">{{ errorMsg }}</p>
</div>
<footer class="wp-stm__foot">
<Button size="sm" data-test="save-tpl-cancel" @click="emit('close')">Cancel</Button>
<span class="wp-stm-spacer" />
<Button
v-if="updateTargetId"
size="sm"
:disabled="!canSubmit"
data-test="save-tpl-save-new"
@click="submit('new')"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
:disabled="!canSubmit"
data-test="save-tpl-submit"
@click="submit(updateTargetId ? 'update' : 'new')"
>{{ updateTargetId ? "Update existing" : "Save" }}</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="save-tpl-cancel" @click="emit('close')">Cancel</Button>
<span class="wp-stm-spacer" />
<Button
v-if="updateTargetId"
size="sm"
:disabled="!canSubmit"
data-test="save-tpl-save-new"
@click="submit('new')"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
:disabled="!canSubmit"
data-test="save-tpl-submit"
@click="submit(updateTargetId ? 'update' : 'new')"
>{{ updateTargetId ? "Update existing" : "Save" }}</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-stm {
width: min(560px, 100%);
max-height: calc(100vh - 40px);
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 12px);
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.55));
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
}
.wp-stm__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient, transparent);
position: relative;
}
.wp-stm__head::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-stm__head > * { position: relative; z-index: 1; }
.wp-stm__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-stm { display: flex; flex-direction: column; color: var(--wp-text); font-family: var(--wp-font-sans, sans-serif); }
.wp-stm__body {
flex: 1;
@@ -280,13 +246,5 @@ async function submit(mode: "update" | "new") {
color: var(--wp-danger);
}
.wp-stm__foot {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
}
.wp-stm-spacer { flex: 1; }
</style>
+8 -31
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Body-teleported modal for the cleaner's blocklist. Mode toggle:
* Kit Modal for the cleaner's blocklist. Mode toggle:
* - "list" — comma- or newline-separated, exact tag match (case-insensitive)
* - "regex" — one regex per line, case-insensitive
*
@@ -8,7 +8,7 @@
* Cancel discards. Parent gates visibility via `visible`.
*/
import { ref, watch, computed } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Textarea from "@/kit/Textarea.vue";
import type { BlocklistKind } from "./types";
import Button from "@/kit/Button.vue";
@@ -60,11 +60,8 @@ function onCancel(): void {
</script>
<template>
<ModalShell :visible="visible" @close="onCancel">
<Modal :open="visible" title="Blocklist" size="md" flush @update:open="(v: boolean) => { if (!v) onCancel(); }">
<div class="wp-blocklist">
<header class="wp-blocklist__head">
<span class="wp-blocklist__title">Blocklist</span>
</header>
<div class="wp-blocklist__mode-row">
<span class="wp-blocklist__label">MODE</span>
<WpSegmented
@@ -93,28 +90,15 @@ function onCancel(): void {
</span>
</div>
</div>
<footer class="wp-blocklist__foot">
<Button size="sm" data-test="blocklist-cancel" @click="onCancel">Cancel</Button>
<Button size="sm" variant="primary" data-test="blocklist-save" @click="onSave">Save</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="blocklist-cancel" @click="onCancel">Cancel</Button>
<Button size="sm" variant="primary" data-test="blocklist-save" @click="onSave">Save</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-blocklist {
width: 480px;
max-width: 90vw;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius, 6px);
color: var(--wp-text);
}
.wp-blocklist__head {
padding: 12px 16px;
border-bottom: 1px solid var(--wp-border);
}
.wp-blocklist__title { font-size: 14px; font-weight: 600; }
.wp-blocklist__mode-row {
padding: 10px 16px;
border-bottom: 1px solid var(--wp-border);
@@ -151,11 +135,4 @@ function onCancel(): void {
display: flex;
gap: 6px;
}
.wp-blocklist__foot {
padding: 10px 16px 12px;
display: flex;
gap: 6px;
justify-content: flex-end;
border-top: 1px solid var(--wp-border);
}
</style>
+18 -25
View File
@@ -7,12 +7,11 @@
* run, so the grid can be checked before anything is queued.
*/
import { computed } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import SweepPanel from "./SweepPanel.vue";
import { liveAxes, sweepTotal, type SweepConfig } from "./types";
import type { SweepCandidate } from "./sweep-candidates";
import Button from "@/kit/Button.vue";
import Icon from "@/kit/Icon.vue";
const props = withDefaults(
defineProps<{
@@ -63,17 +62,14 @@ const preview = computed(() => {
</script>
<template>
<ModalShell :visible="true" @close="emit('close')">
<div class="wp-swm" data-test="sweep-modal">
<div class="wp-swm__head">
<span class="wp-swm__head-icon"><Icon name="th-large" :size="15" /></span>
<div class="wp-swm__title-block">
<div class="wp-swm__title-row"><span class="wp-swm__name">WP Context Loop</span><span class="wp-swm__chip">Sweep</span></div>
<div class="wp-swm__sub">Run every combination of the wildcards you pick, one frame each.</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="sweep-modal-close" @click="emit('close')" />
<Modal :open="true" size="md" flush accent="var(--wp-accent)" icon="th-large" @update:open="(v: boolean) => { if (!v) emit('close'); }">
<template #header="{ titleId }">
<div class="wp-swm__title-block">
<div class="wp-swm__title-row"><span :id="titleId" class="wp-swm__name">WP Context Loop</span><span class="wp-swm__chip">Sweep</span></div>
<div class="wp-swm__sub">Run every combination of the wildcards you pick, one frame each.</div>
</div>
</template>
<div class="wp-swm" data-test="sweep-modal">
<div class="wp-swm__body">
<SweepPanel
:model-value="modelValue"
@@ -94,23 +90,21 @@ const preview = computed(() => {
</div>
</div>
<div class="wp-swm__foot">
<span class="wp-swm__foot-hint">Changes save as you make them.</span>
<span class="wp-swm__spacer" />
<Button size="sm" variant="primary" data-test="sweep-modal-done" @click="emit('close')">Done</Button>
</div>
</div>
</ModalShell>
<template #footer>
<span class="wp-swm__foot-hint">Changes save as you make them.</span>
<span class="wp-swm__spacer" />
<Button size="sm" variant="primary" data-test="sweep-modal-done" @click="emit('close')">Done</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-swm { width: 520px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: min(6px, var(--wp-radius-cap, 999px)); overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: var(--wp-shadow-lg); }
.wp-swm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: color-mix(in srgb, var(--wp-accent) 9%, var(--wp-bg2)); }
.wp-swm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
.wp-swm__title-block { flex: 1; min-width: 0; }
.wp-swm { display: flex; flex-direction: column; min-height: 0; color: var(--wp-text); font-size: 12px; }
.wp-swm__title-block { flex: 1 1 auto; min-width: 0; }
.wp-swm__title-row { display: flex; align-items: center; gap: 8px; }
.wp-swm__name { font: 600 13px var(--wp-font-sans); }
.wp-swm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
.wp-swm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-modal-accent) 22%, transparent); color: var(--wp-modal-accent); }
.wp-swm__sub { font: 10.5px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
.wp-swm__body { overflow-y: auto; padding: 12px 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; font-size: 11px; }
@@ -122,7 +116,6 @@ const preview = computed(() => {
.wp-swm__frame-vals { color: var(--wp-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-swm__frame-more { padding: 4px 0 0 42px; font: 10.5px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.wp-swm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.wp-swm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.wp-swm__foot-hint { align-self: center; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.wp-swm__spacer { flex: 1; }
</style>
+38 -73
View File
@@ -2,7 +2,7 @@
import Kbd from "@/kit/Kbd.vue";
import SearchInput from "@/kit/SearchInput.vue";
import { computed, nextTick, ref, watch } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import ConfirmModal from "@/kit/ConfirmModal.vue";
import { api } from "../../manager/api/client";
import type { BundleRow } from "../../manager/api/types";
@@ -178,19 +178,17 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
</script>
<template>
<ModalShell :visible="visible" label="Add bundle from library" @close="emit('close')" @keydown="onKeydown">
<div class="wp-bp">
<header class="wp-bp__head">
<div class="wp-bp__head-text">
<div class="wp-bp__eyebrow">Add bundle from library</div>
<div class="wp-bp__title">
Browse Bundles
<span class="wp-bp__title-detail">· {{ filtered.length }} {{ filtered.length === 1 ? "bundle" : "bundles" }}</span>
</div>
<Modal :open="visible" size="lg" flush accent="brand" icon="box" @update:open="(v: boolean) => { if (!v) emit('close'); }" @keydown="onKeydown">
<template #header="{ titleId }">
<div class="wp-bp__head-text">
<div class="wp-bp__eyebrow">Add bundle from library</div>
<div :id="titleId" class="wp-bp__title">
Browse Bundles
<span class="wp-bp__title-detail">· {{ filtered.length }} {{ filtered.length === 1 ? "bundle" : "bundles" }}</span>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" @click="emit('close')" />
</header>
</div>
</template>
<div class="wp-bp">
<div class="wp-bp__toolbar">
<SearchInput
ref="searchInput"
@@ -263,32 +261,32 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
</div>
</div>
<div class="wp-bp__foot">
<div class="wp-bp__hints">
<Kbd keys="↑" size="sm" />
<Kbd keys="↓" size="sm" />
<span class="wp-bp__hint-label">nav</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Enter" size="sm" />
<span class="wp-bp__hint-label">insert</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="/" size="sm" />
<span class="wp-bp__hint-label">search</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Esc" size="sm" />
<span class="wp-bp__hint-label">cancel</span>
</div>
<Button size="sm" @click="emit('close')">Cancel</Button>
<Button
size="sm"
variant="primary"
icon="pi-plus"
data-testid="bundle-picker-create"
@click="emit('create')"
>Create Bundle</Button>
</div>
</div>
</ModalShell>
<template #footer>
<div class="wp-bp__hints">
<Kbd keys="↑" size="sm" />
<Kbd keys="↓" size="sm" />
<span class="wp-bp__hint-label">nav</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Enter" size="sm" />
<span class="wp-bp__hint-label">insert</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="/" size="sm" />
<span class="wp-bp__hint-label">search</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Esc" size="sm" />
<span class="wp-bp__hint-label">cancel</span>
</div>
<Button size="sm" @click="emit('close')">Cancel</Button>
<Button
size="sm"
variant="primary"
icon="pi-plus"
data-testid="bundle-picker-create"
@click="emit('create')"
>Create Bundle</Button>
</template>
</Modal>
<ConfirmModal
:open="pendingReinsert !== null"
:title="confirmTitle"
@@ -305,37 +303,12 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
.wp-bp__skel-list { padding: 6px 16px; }
.wp-bp {
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: min(8px, var(--wp-radius-cap, 999px));
width: 720px;
max-width: 92vw;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.5));
min-height: 0;
}
/* Header — brand gradient + dark wash overlay, matches edit modals. */
.wp-bp__head {
background: var(--wp-brand-gradient);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--wp-border);
position: relative;
}
.wp-bp__head::after {
content: "";
position: absolute;
inset: 0;
/* Theme-aware wash so the brand gradient reads correctly in both palettes. */
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-bp__head > * { position: relative; z-index: 1; }
.wp-bp__head-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.wp-bp__head-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.wp-bp__eyebrow {
font: 500 11px/1.2 var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
@@ -461,14 +434,6 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
/* Footer — keyboard hints inline with Cancel + Create. Matches the
* module picker pattern: the keys are the kit's Kbd. */
.wp-bp__foot {
border-top: 1px solid var(--wp-border);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
background: var(--wp-bg-deep, var(--wp-bg));
}
.wp-bp__hints {
display: inline-flex;
align-items: center;
+21 -48
View File
@@ -1,22 +1,19 @@
<template>
<ModalShell :visible="visible" @close="$emit('close')">
<div ref="rootEl" class="wp-picker" tabindex="-1">
<!-- Header -->
<header class="wp-picker__head">
<div class="wp-picker__head-text">
<div class="wp-picker__eyebrow">
Add modules from library
</div>
<div class="wp-picker__title">
Browse Library
<span class="wp-picker__title-detail">
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
</span>
</div>
<Modal :open="visible" size="lg" flush accent="brand" icon="th-large" @update:open="(v: boolean) => { if (!v) $emit('close'); }">
<template #header="{ titleId }">
<div class="wp-picker__head-text">
<div class="wp-picker__eyebrow">
Add modules from library
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" @click="$emit('close')" />
</header>
<div :id="titleId" class="wp-picker__title">
Browse Library
<span class="wp-picker__title-detail">
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
</span>
</div>
</div>
</template>
<div ref="rootEl" class="wp-picker" tabindex="-1">
<!-- Toolbar: search + kind tabs + compact filter buttons.
Filters are inline on the toolbar (no dedicated row) so
@@ -309,7 +306,7 @@
>Add {{ selected.size }} {{ selected.size === 1 ? "module" : "modules" }}</Button>
</footer>
</div>
</ModalShell>
</Modal>
</template>
<script setup lang="ts">
@@ -332,7 +329,7 @@
import Kbd from "@/kit/Kbd.vue";
import SearchInput from "@/kit/SearchInput.vue";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Skeleton from "@/kit/Skeleton.vue";
import WpSelect from "@/kit/WpSelect.vue";
import { kindIcon } from "../shared/kind-icons";
@@ -970,41 +967,17 @@ onBeforeUnmount(detachCaptureListeners);
.wp-picker__skel-rows { padding: 0 10px; }
.wp-picker {
background: var(--wp-bg-2);
border: 1px solid var(--wp-border-strong);
border-radius: var(--wp-radius-lg);
box-shadow: var(--wp-shadow-lg);
width: 780px;
max-width: calc(100vw - 32px);
height: 600px;
max-height: calc(100vh - 32px);
height: 560px;
max-height: calc(100vh - 120px);
display: flex;
flex-direction: column;
color: var(--wp-text);
overflow: hidden;
font-family: var(--wp-font);
outline: none;
}
/* ── Header — brand gradient + dark wash overlay. Mirrors BundlePicker
* so both pickers read as part of the same family. */
.wp-picker__head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient);
position: relative;
}
.wp-picker__head::after {
content: "";
position: absolute;
inset: 0;
/* Theme-aware wash so the brand gradient stays subtle in both
* palettes — dark bg in dark mode, near-white in light mode. */
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-picker__head > * { position: relative; z-index: 1; }
.wp-picker__head-text { flex: 1; min-width: 0; }
/* ── Header text (Modal draws the brand wash). */
.wp-picker__head-text { flex: 1 1 auto; min-width: 0; }
.wp-picker__eyebrow {
font-size: 11px;
text-transform: uppercase;
@@ -29,7 +29,7 @@ import { bundleContentKey } from "./bundles/bundle-fingerprint";
import type { BundleInstance } from "../../widgets/_shared";
import Checkbox from "@/kit/Checkbox.vue";
import Button from "@/kit/Button.vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import { vToggleRow } from "@/kit/toggle-row";
interface ChildPreview {
@@ -421,16 +421,8 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
</script>
<template>
<ModalShell :visible="!!(open && bundle)" labelledby="wp-pbtl-title" @close="close">
<div
class="wp-ptl"
data-test="pbtl"
>
<header class="wp-ptl__head">
<h3 id="wp-pbtl-title" class="wp-ptl__title">Push bundle to library</h3>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="pbtl-close" @click="close" />
</header>
<Modal :open="!!(open && bundle)" title="Push bundle to library" size="md" flush @update:open="(v: boolean) => { if (!v) close(); }">
<div class="wp-ptl" data-test="pbtl">
<div class="wp-ptl__body">
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-pbtl-name">Name</label>
@@ -605,46 +597,30 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
</div>
</div>
<footer class="wp-ptl__foot">
<Button size="sm" data-test="pbtl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="pbtl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="pbtl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="pbtl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="pbtl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="pbtl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-ptl {
width: min(620px, 100%);
max-height: calc(100vh - 32px);
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 10px);
box-shadow: var(--wp-shadow-lg);
display: flex; flex-direction: column;
overflow: hidden;
color: var(--wp-text);
}
.wp-ptl__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
}
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-ptl { color: var(--wp-text); }
.wp-ptl__body {
flex: 1; min-height: 0; overflow-y: auto;
padding: 14px;
@@ -801,11 +777,5 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
color: var(--wp-danger);
font-size: 12px;
}
.wp-ptl__foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
}
.wp-ptl-spacer { flex: 1; }
</style>
+23 -53
View File
@@ -41,7 +41,7 @@ import type { ModuleEntry } from "../../widgets/_shared";
import Checkbox from "@/kit/Checkbox.vue";
import LoadingMark from "@/kit/LoadingMark.vue";
import Button from "@/kit/Button.vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import CodeBlock from "@/kit/CodeBlock.vue";
import { vToggleRow } from "@/kit/toggle-row";
@@ -443,16 +443,8 @@ async function doSaveAsNew(): Promise<void> {
</script>
<template>
<ModalShell :visible="!!(open && draft)" labelledby="wp-ptl-title" @close="close">
<div
class="wp-ptl"
data-test="ptl"
>
<header class="wp-ptl__head">
<h3 id="wp-ptl-title" class="wp-ptl__title">Push to library</h3>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="ptl-close" @click="close" />
</header>
<Modal :open="!!(open && draft)" title="Push to library" size="md" flush @update:open="(v: boolean) => { if (!v) close(); }">
<div class="wp-ptl" data-test="ptl">
<div class="wp-ptl__body">
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-ptl-name">Name</label>
@@ -603,46 +595,30 @@ async function doSaveAsNew(): Promise<void> {
</div>
</div>
<footer class="wp-ptl__foot">
<Button size="sm" data-test="ptl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="ptl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="ptl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="ptl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="ptl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="ptl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-ptl {
width: min(620px, 100%);
max-height: calc(100vh - 32px);
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 10px);
box-shadow: var(--wp-shadow-lg);
display: flex; flex-direction: column;
overflow: hidden;
color: var(--wp-text);
}
.wp-ptl__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
}
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-ptl { color: var(--wp-text); }
.wp-ptl__body {
flex: 1; min-height: 0; overflow-y: auto;
padding: 14px;
@@ -759,11 +735,5 @@ async function doSaveAsNew(): Promise<void> {
color: var(--wp-danger);
font-size: 12px;
}
.wp-ptl__foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
}
.wp-ptl-spacer { flex: 1; }
</style>
+55 -46
View File
@@ -15,7 +15,7 @@
* it: the run keeps waiting until one of the three answers is sent.
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Textarea from "@/kit/Textarea.vue";
import WpSegmented, { type SegmentedOption } from "@/kit/WpSegmented.vue";
import WpImageZoom from "@/kit/WpImageZoom.vue";
@@ -263,8 +263,9 @@ const painting = ref(false);
const pickerEl = ref<HTMLElement | null>(null);
function keepFocusInside(): void {
const root = pickerEl.value;
const dialog = root?.closest<HTMLElement>(".wp-modal") ?? root;
const at = document.activeElement;
if (root && !(at instanceof Node && root.contains(at))) root.focus({ preventScroll: true });
if (root && !(at instanceof Node && dialog?.contains(at))) root.focus({ preventScroll: true });
}
watch([mode, () => painting.value], () => { void nextTick(keepFocusInside); }, { flush: "post" });
const tool = ref<MaskTool>("brush");
@@ -445,6 +446,9 @@ const now = ref(Date.now());
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
timer = setInterval(() => { now.value = Date.now(); }, 1000);
// Focus the picker itself, not its first control (Modal leaves focus where it
// is when it is already inside): the keys work from there, and no tooltip pops.
pickerEl.value?.focus({ preventScroll: true });
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
@@ -546,33 +550,40 @@ const subLine = computed(() => {
</script>
<template>
<ModalShell :visible="true" @close="onClose" @keydown="onKey">
<div ref="pickerEl" class="wp-ifp" :class="[`is-${mode}`, { 'is-fit': isFit }]" data-test="image-filter-picker" :data-mode="mode" tabindex="-1">
<div class="wp-ifp__head">
<i class="pi pi-filter wp-ifp__head-icon" aria-hidden="true" />
<div class="wp-ifp__title-block">
<div class="wp-ifp__title-row">
<span class="wp-ifp__name">WP Image Filter</span>
<span class="wp-ifp__chip">Waiting</span>
<span v-if="moreWaiting" class="wp-ifp__more" data-test="image-filter-more">+{{ moreWaiting }} more waiting</span>
</div>
<div class="wp-ifp__sub">
<template v-if="multiFrame">{{ frames.length }} frames, {{ total }} images.</template>
<template v-else>{{ total }} {{ total === 1 ? "image" : "images" }}.</template>
{{ subLine }}
</div>
<Modal
:open="true"
size="full"
flush
accent="var(--wp-accent)"
icon="filter"
@update:open="(v: boolean) => { if (!v) onClose(); }"
@keydown="onKey"
>
<template #header="{ titleId }">
<div class="wp-ifp__title-block">
<div class="wp-ifp__title-row">
<span :id="titleId" class="wp-ifp__name">WP Image Filter</span>
<span class="wp-ifp__chip">Waiting</span>
<span v-if="moreWaiting" class="wp-ifp__more" data-test="image-filter-more">+{{ moreWaiting }} more waiting</span>
</div>
<div class="wp-ifp__sub">
<template v-if="multiFrame">{{ frames.length }} frames, {{ total }} images.</template>
<template v-else>{{ total }} {{ total === 1 ? "image" : "images" }}.</template>
{{ subLine }}
</div>
<WpSegmented
:model-value="mode"
:options="MODE_OPTIONS"
size="sm"
aria-label="Mode"
test-prefix="image-filter-mode"
:disabled="!total"
@update:model-value="(m) => setMode(m)"
/>
<Button size="sm" variant="ghost" icon="pi-minus" aria-label="Hide for now (the run keeps waiting)" data-test="image-filter-minimize" @click="emit('minimize')" />
</div>
<WpSegmented
:model-value="mode"
:options="MODE_OPTIONS"
size="sm"
aria-label="Mode"
test-prefix="image-filter-mode"
:disabled="!total"
@update:model-value="(m) => setMode(m)"
/>
<Button size="sm" variant="ghost" icon="pi-minus" aria-label="Hide for now (the run keeps waiting)" data-test="image-filter-minimize" @click="emit('minimize')" />
</template>
<div ref="pickerEl" class="wp-ifp" :class="[`is-${mode}`, { 'is-fit': isFit }]" data-test="image-filter-picker" :data-mode="mode" tabindex="-1">
<!-- Grid -->
<template v-if="mode === 'grid'">
@@ -993,28 +1004,27 @@ const subLine = computed(() => {
</WpImageZoom>
</div>
<div class="wp-ifp__foot">
<div class="wp-ifp__status" data-test="image-filter-status">
<span><strong>{{ pickedList.length }}</strong> of {{ total }} picked<template v-if="multiFrame"> from {{ pickedFrames }} {{ pickedFrames === 1 ? "frame" : "frames" }}</template></span>
<span v-if="editCount" class="wp-ifp__edits">{{ editCount }} refined</span>
<span v-if="leftLabel" class="wp-ifp__left" :class="{ 'is-low': (left ?? 99) <= 30 }">{{ leftLabel }}</span>
</div>
<span class="wp-ifp__spacer" />
<Button v-if="canSendToLoop" size="sm" :disabled="pickedFrames === 0" hint="Lock the picked frames' seeds in the Context Loop and bypass the rest, so the next run repeats only your picks" data-test="image-filter-send-loop" @click="sendToLoop">Send to Loop</Button>
<Button size="sm" variant="danger" hint="Skip everything after this node for this run" data-test="image-filter-stop" @click="stop">Stop branch</Button>
<Button size="sm" data-test="image-filter-keep-all" @click="keepAll">Keep all {{ total }}</Button>
<Button size="sm" variant="primary" kbd="Enter" :disabled="pickedList.length === 0" data-test="image-filter-keep-picked" @click="keepPicked">{{ pickedList.length ? `Keep ${pickedList.length} picked` : "Keep picked" }}</Button>
</div>
</div>
</ModalShell>
<template #footer>
<div class="wp-ifp__status" data-test="image-filter-status">
<span><strong>{{ pickedList.length }}</strong> of {{ total }} picked<template v-if="multiFrame"> from {{ pickedFrames }} {{ pickedFrames === 1 ? "frame" : "frames" }}</template></span>
<span v-if="editCount" class="wp-ifp__edits">{{ editCount }} refined</span>
<span v-if="leftLabel" class="wp-ifp__left" :class="{ 'is-low': (left ?? 99) <= 30 }">{{ leftLabel }}</span>
</div>
<span class="wp-ifp__spacer" />
<Button v-if="canSendToLoop" size="sm" :disabled="pickedFrames === 0" hint="Lock the picked frames' seeds in the Context Loop and bypass the rest, so the next run repeats only your picks" data-test="image-filter-send-loop" @click="sendToLoop">Send to Loop</Button>
<Button size="sm" variant="danger" hint="Skip everything after this node for this run" data-test="image-filter-stop" @click="stop">Stop branch</Button>
<Button size="sm" data-test="image-filter-keep-all" @click="keepAll">Keep all {{ total }}</Button>
<Button size="sm" variant="primary" kbd="Enter" :disabled="pickedList.length === 0" data-test="image-filter-keep-picked" @click="keepPicked">{{ pickedList.length ? `Keep ${pickedList.length} picked` : "Keep picked" }}</Button>
</template>
</Modal>
</template>
<style scoped>
/* One size in every mode, so switching never moves the window under the mouse. */
.wp-ifp { width: min(1280px, 95vw); height: 90vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: min(6px, var(--wp-radius-cap, 999px)); overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: var(--wp-shadow-lg); outline: none; }
.wp-ifp__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: color-mix(in srgb, var(--wp-accent) 9%, var(--wp-bg2)); }
.wp-ifp__head-icon { color: var(--wp-accent); width: 24px; font-size: 15px; text-align: center; }
.wp-ifp__title-block { flex: 1; min-width: 0; }
.wp-ifp { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--wp-text); font-size: 12px; outline: none; }
/* Grows past the kit header spacer so the mode switch sits at the right, by the ×. */
.wp-ifp__title-block { flex: 999 1 auto; min-width: 0; }
.wp-ifp__title-row { display: flex; align-items: center; gap: 8px; }
.wp-ifp__name { font: 600 13px var(--wp-font-sans); }
.wp-ifp__chip { font: 600 10px var(--wp-font-sans); letter-spacing: .02em; padding: 2px 6px; border-radius: min(3px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent-text, var(--wp-accent)); }
@@ -1132,8 +1142,7 @@ const subLine = computed(() => {
.wp-ifp__full-strip .is-current { border-color: var(--wp-accent); }
.wp-ifp__full-sep { width: 1px; height: 32px; background: var(--wp-border); }
.wp-ifp__foot { display: flex; align-items: center; gap: 8px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.wp-ifp__status { display: flex; gap: 12px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
.wp-ifp__status { align-self: center; display: flex; gap: 12px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
.wp-ifp__status strong { color: var(--wp-text); }
.wp-ifp__edits { color: var(--wp-accent-text, var(--wp-accent)); }
.wp-ifp__left { color: var(--wp-text-dim, var(--wp-text3)); font-family: var(--wp-font-mono, monospace); }
@@ -33,7 +33,7 @@ import RichTextInput from "../../manager/components/RichTextInput.vue";
import { INLINE_PICKS_NOTE, hasInlinePicks } from "../context/editors/_shared/preview-tokens";
import Button from "@/kit/Button.vue";
import ListRow from "@/kit/ListRow.vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Popover from "@/kit/Popover.vue";
const props = withDefaults(
@@ -355,20 +355,25 @@ function onKeydown(ev: KeyboardEvent): void {
</script>
<template>
<ModalShell :visible="true" labelledby="ibm-title" @close="onShellClose" @keydown="onKeydown">
<div class="ibm" tabindex="-1">
<header class="ibm__head" :style="{ '--ibm-accent': headerColor }">
<i :class="['pi', headerIcon, 'ibm__head-icon']" aria-hidden="true" />
<div class="ibm__title-block">
<div class="ibm__title-row">
<span id="ibm-title" class="ibm__name" data-test="ibm-name">{{ headerName }}</span>
<span class="ibm__chip" data-test="ibm-chip">{{ headerType }}</span>
</div>
<div class="ibm__sub">Injector binding · ctx[$<wbr>{{ draftBinding || "…" }}] ← {{ templateValue ? "template" : isGeneral ? "no template yet" : "socket value" }}</div>
<Modal
:open="true"
size="md"
flush
:accent="headerColor"
:icon="headerIcon.replace(/^pi-/, '')"
@update:open="(v: boolean) => { if (!v) onShellClose(); }"
@keydown="onKeydown"
>
<template #header="{ titleId }">
<div class="ibm__title-block">
<div class="ibm__title-row">
<span :id="titleId" class="ibm__name" data-test="ibm-name">{{ headerName }}</span>
<span class="ibm__chip" data-test="ibm-chip">{{ headerType }}</span>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="ibm-close" @click="onCancel" />
</header>
<div class="ibm__sub">Injector binding · ctx[$<wbr>{{ draftBinding || "…" }}] ← {{ templateValue ? "template" : isGeneral ? "no template yet" : "socket value" }}</div>
</div>
</template>
<div class="ibm">
<section class="ibm__section">
<div class="ibm__section-head">
<span class="ibm__section-label">Binding</span>
@@ -564,15 +569,15 @@ function onKeydown(ev: KeyboardEvent): void {
</template>
</section>
<footer class="ibm__foot">
<span class="ibm__hint">
<Kbd keys="Esc" size="sm" /> cancel · <Kbd keys="Mod+Enter" size="sm" /> save
</span>
<Button size="sm" data-test="ibm-cancel" @click="onCancel">Cancel</Button>
<Button size="sm" variant="primary" :disabled="!isDirty" data-test="ibm-save" @click="onSave">Save</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<span class="ibm__hint">
<Kbd keys="Esc" size="sm" /> cancel · <Kbd keys="Mod+Enter" size="sm" /> save
</span>
<Button size="sm" data-test="ibm-cancel" @click="onCancel">Cancel</Button>
<Button size="sm" variant="primary" :disabled="!isDirty" data-test="ibm-save" @click="onSave">Save</Button>
</template>
</Modal>
</template>
<!-- Shared modal-template-controls partial — imported UNSCOPED so the
@@ -590,37 +595,12 @@ function onKeydown(ev: KeyboardEvent): void {
<style scoped>
.ibm {
width: 100%;
max-width: 520px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius, 6px);
box-shadow: var(--wp-shadow-lg);
display: flex;
flex-direction: column;
max-height: calc(100vh - 48px);
overflow: hidden;
min-height: 0;
}
.ibm__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--ibm-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%,
var(--wp-bg2) 100%
);
border-bottom: 1px solid var(--wp-border);
}
.ibm__head-icon {
font-size: 18px;
color: var(--ibm-accent, var(--wp-accent));
width: 24px;
text-align: center;
}
.ibm__title-block { flex: 1; min-width: 0; }
.ibm__title-block { flex: 1 1 auto; min-width: 0; }
.ibm__title-row {
display: flex;
align-items: center;
@@ -636,8 +616,8 @@ function onKeydown(ev: KeyboardEvent): void {
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: min(2px, var(--wp-radius-cap, 999px));
background: color-mix(in srgb, var(--ibm-accent, var(--wp-accent)) 22%, transparent);
color: var(--ibm-accent, var(--wp-accent));
background: color-mix(in srgb, var(--wp-modal-accent, var(--wp-accent)) 22%, transparent);
color: var(--wp-modal-accent, var(--wp-accent));
}
.ibm__sub {
font: 400 10px var(--wp-font-mono);
@@ -799,15 +779,9 @@ function onKeydown(ev: KeyboardEvent): void {
font-weight: 600;
}
.ibm__foot {
padding: 10px 16px;
border-top: 1px solid var(--wp-border-soft, var(--wp-border));
display: flex;
align-items: center;
gap: 12px;
}
.ibm__hint {
margin-right: auto;
align-self: center;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
+23 -52
View File
@@ -7,7 +7,7 @@
*/
import { computed, ref } from "vue";
import Button from "@/kit/Button.vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import WpSegmented from "@/kit/WpSegmented.vue";
import { kindIcon } from "../shared/kind-icons";
import { choicesFor, countLabel, type Choice, type LibraryPlan, type PlanItem } from "./note-library-plan";
@@ -51,12 +51,14 @@ function close(result: Record<string, Choice> | null): void {
</script>
<template>
<ModalShell :visible="visible" labelledby="wp-note-lib-title" @close="close(null)">
<div class="wp-note-lib" data-test="note-library-dialog">
<header class="wp-note-lib__head">
<span id="wp-note-lib-title" class="wp-note-lib__title">Add to your library</span>
<Modal :open="visible" size="md" flush @update:open="(v: boolean) => { if (!v) close(null); }">
<template #header="{ titleId }">
<div class="wp-note-lib__head">
<span :id="titleId" class="wp-note-lib__title">Add to your library</span>
<span class="wp-note-lib__sub">From {{ scope }}</span>
</header>
</div>
</template>
<div class="wp-note-lib" data-test="note-library-dialog">
<div class="wp-note-lib__body">
<section v-if="fresh.length" class="wp-note-lib__group">
<h3 class="wp-note-lib__label">New: {{ countLabel(fresh) }}</h3>
@@ -95,51 +97,27 @@ function close(result: Record<string, Choice> | null): void {
</p>
<p class="wp-note-lib__hint">One step: Undo on the message afterwards takes it all back out.</p>
</div>
<footer class="wp-note-lib__foot">
<Button size="sm" data-test="note-library-cancel" @click="close(null)">Cancel</Button>
<Button
size="sm"
variant="primary"
icon="pi-download"
:disabled="going.length === 0"
data-test="note-library-add"
@click="close(choices)"
>{{ going.length ? `Add ${countLabel(going)}` : "Nothing to add" }}</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="note-library-cancel" @click="close(null)">Cancel</Button>
<Button
size="sm"
variant="primary"
icon="pi-download"
:disabled="going.length === 0"
data-test="note-library-add"
@click="close(choices)"
>{{ going.length ? `Add ${countLabel(going)}` : "Nothing to add" }}</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-note-lib {
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: min(8px, var(--wp-radius-cap, 999px));
color: var(--wp-text);
font-size: 13px;
}
.wp-note-lib__head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 16px;
border-bottom: 1px solid var(--wp-border);
}
.wp-note-lib { color: var(--wp-text); font-size: 13px; }
.wp-note-lib__head { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
.wp-note-lib__title { font-size: 14px; font-weight: 600; }
.wp-note-lib__sub { font-size: 12px; color: var(--wp-text-muted); }
.wp-note-lib__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 16px;
overflow-y: auto;
overscroll-behavior: contain;
}
.wp-note-lib__body { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; }
.wp-note-lib__group { display: flex; flex-direction: column; gap: 6px; }
.wp-note-lib__label {
margin: 0;
@@ -176,11 +154,4 @@ function close(result: Record<string, Choice> | null): void {
}
.wp-note-lib__note { margin: 0; font-size: 12px; color: var(--wp-text-muted); }
.wp-note-lib__hint { margin: 0; font-size: 11px; color: var(--wp-text-dim); }
.wp-note-lib__foot {
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 10px 16px 12px;
border-top: 1px solid var(--wp-border);
}
</style>
@@ -40,8 +40,7 @@ import PlaygroundBehaviorPreview from "./PlaygroundBehaviorPreview.vue";
import Checkbox from "@/kit/Checkbox.vue";
import WpSelect from "@/kit/WpSelect.vue";
import Button from "@/kit/Button.vue";
import ModalShell from "@/kit/ModalShell.vue";
import { useDialogFocus } from "@/kit/useDialogFocus";
import Modal from "@/kit/Modal.vue";
import { useHostDialogGuard } from "@/components/shared/useHostDialogGuard";
import { vToggleRow } from "@/kit/toggle-row";
@@ -263,18 +262,12 @@ function onClose(): void {
}
// The playground opens from ComfyUI's Settings dialog, which stays open
// underneath. ModalShell gives the overlay, the backdrop and Escape close and
// the key shield; useDialogFocus moves focus in, keeps Tab inside (run from
// the shell's keydown, since the shield stops keys before window) and puts
// focus back; the host guard, bound to the shell's overlay, stops the
// Settings dialog from swallowing clicks, pulling focus back or closing
// along with us.
// underneath. Modal gives the overlay, the backdrop and Escape close, the key
// shield and the focus trap + return; the host guard, bound to the modal's
// overlay, stops the Settings dialog from swallowing clicks, pulling focus
// back or closing along with us.
const panelEl = ref<HTMLElement | null>(null);
const overlayEl = computed(() => panelEl.value?.closest<HTMLElement>(".wp-modal-overlay") ?? null);
const focus = useDialogFocus(panelEl, () => playgroundOpen.value, {
onEscape: () => undefined,
window: false,
});
useHostDialogGuard(overlayEl, () => playgroundOpen.value);
function onOpenChange(open: boolean): void {
@@ -307,26 +300,27 @@ onBeforeUnmount(() => {
</script>
<template>
<ModalShell :visible="playgroundOpen" labelledby="wp-pg-title" @close="onClose" @keydown="focus.onKeydown">
<Modal
:open="playgroundOpen"
size="lg"
flush
accent="brand"
icon="palette"
@update:open="(v: boolean) => { if (!v) onClose(); }"
>
<template #header="{ titleId }">
<div class="wp-pg__head-text">
<h3 :id="titleId" class="wp-pg__title">Display Playground</h3>
<p class="wp-pg__subtitle">
Tweak Wildcard Pipeline visuals — changes apply live.
</p>
</div>
</template>
<section
ref="panelEl"
class="wp-pg"
tabindex="-1"
data-test="playground-panel"
>
<header class="wp-pg__head">
<div class="wp-pg__head-inner">
<i class="pi pi-palette wp-pg__head-icon" aria-hidden="true" />
<div>
<h3 id="wp-pg-title" class="wp-pg__title">Display Playground</h3>
<p class="wp-pg__subtitle">
Tweak Wildcard Pipeline visuals — changes apply live.
</p>
</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close playground" size="sm" kbd="Esc" @click="onClose" />
</header>
<div class="wp-pg__body">
<section class="wp-pg__controls" data-test="playground-controls">
<fieldset class="wp-pg__group">
@@ -538,12 +532,13 @@ onBeforeUnmount(() => {
</aside>
</div>
<footer class="wp-pg__foot">
<Button size="md" @click="onResetAll">Reset all to defaults</Button>
<Button variant="primary" size="md" @click="onClose">Done</Button>
</footer>
</section>
</ModalShell>
<template #footer>
<Button size="md" @click="onResetAll">Reset all to defaults</Button>
<span class="wp-pg__foot-spacer" />
<Button variant="primary" size="md" @click="onClose">Done</Button>
</template>
</Modal>
</template>
<style>
@@ -553,52 +548,12 @@ onBeforeUnmount(() => {
* subservient to unlayered ones in the same origin. */
@layer wp-extension {
.wp-pg {
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
box-shadow: var(--wp-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.5));
width: min(960px, 100%);
max-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
min-height: 0;
color: var(--wp-text);
outline: none;
}
.wp-pg__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient);
position: relative;
}
/* Scrim that knocks the brand gradient back so the title stays readable on
* top of it. It was hardcoded to a near-black rgba, so under the light theme
* the banner stayed dark while `--wp-text` flipped to a DARK ink — dark text
* on a dark band. The scrim now follows the theme: near-black under dark,
* near-white under light, each keeping the same 0.85 knock-back. */
.wp-pg__head::after {
content: "";
position: absolute;
inset: 0;
background: var(--wp-pg-head-scrim, rgba(35, 35, 35, 0.85));
pointer-events: none;
}
.wp-theme-light .wp-pg__head {
--wp-pg-head-scrim: rgba(248, 248, 250, 0.85);
}
.wp-theme-dark .wp-pg__head {
--wp-pg-head-scrim: rgba(35, 35, 35, 0.85);
}
.wp-pg__head > * { position: relative; z-index: 1; }
.wp-pg__head-inner { display: flex; gap: 10px; align-items: flex-start; }
.wp-pg__head-icon {
font-size: 14px;
color: var(--wp-violet);
margin-top: 2px;
}
.wp-pg__head-text { flex: 1 1 auto; min-width: 0; }
.wp-pg__title {
margin: 0;
font: 600 13px/1.2 var(--wp-font-sans);
@@ -659,14 +614,7 @@ onBeforeUnmount(() => {
min-width: 0;
}
.wp-pg__foot {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg2);
}
.wp-pg__foot-spacer { flex: 1; }
/* Narrow viewport — stack panes */
@media (max-width: 720px) {
.wp-pg__body {
@@ -32,8 +32,8 @@ describe("DisplayPlaygroundModal", () => {
await open();
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(dialog?.getAttribute("aria-labelledby")).toBe("wp-pg-title");
expect(document.getElementById("wp-pg-title")?.textContent).toBe("Display Playground");
const titleId = dialog?.getAttribute("aria-labelledby") ?? "";
expect(document.getElementById(titleId)?.textContent).toBe("Display Playground");
expect(document.body.querySelector('[data-test="playground-controls"]')).not.toBeNull();
expect(document.body.querySelector('[data-test="playground-preview"]')).not.toBeNull();
});
@@ -73,7 +73,7 @@ describe("DisplayPlaygroundModal", () => {
it("Done closes it and focus goes back to what opened it", async () => {
const trigger = await open();
expect(document.body.querySelector(".wp-pg")?.contains(document.activeElement)).toBe(true);
expect(document.body.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe(true);
const done = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Done");
done?.click();
await nextTick();