Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1451baf40 | ||
|
|
589f45eb74 | ||
|
|
fa73750ca0 | ||
|
|
b49a579b33 | ||
|
|
34ec18feff | ||
|
|
44d8ab9575 | ||
|
|
68910bfc18 | ||
|
|
b5262c22d5 | ||
|
|
fb19e3cef8 | ||
|
|
43d775ec8f | ||
|
|
86eb15cfa3 | ||
|
|
0f0cd47018 | ||
|
|
1ad7e8e415 | ||
|
|
5a3e834c42 | ||
|
|
cc64ca3ab8 | ||
|
|
fd2cdcbf08 | ||
|
|
48845981a1 | ||
|
|
5da64f8d78 | ||
|
|
fe0d702e75 | ||
|
|
d3e25b72ad | ||
|
|
40980093e8 | ||
|
|
538db06efc | ||
|
|
eac852fbda | ||
|
|
0bbf150ca3 | ||
|
|
c0f354cb8f | ||
|
|
b3537ad6c5 | ||
|
|
a77e60b402 | ||
|
|
c1f45a3fd2 | ||
|
|
11d205bf61 | ||
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d |
@@ -1,3 +1,65 @@
|
||||
## 🎉 Wildcard Pipeline 2.7.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
|
||||
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
|
||||
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
|
||||
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
|
||||
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
|
||||
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
|
||||
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
|
||||
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
|
||||
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
|
||||
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
|
||||
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
|
||||
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
|
||||
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
|
||||
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
|
||||
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
|
||||
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
|
||||
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.6.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
|
||||
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
|
||||
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
|
||||
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
|
||||
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
|
||||
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
|
||||
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
|
||||
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
|
||||
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
|
||||
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.5.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
|
||||
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.4.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -41,6 +41,19 @@ def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
|
||||
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
|
||||
|
||||
|
||||
def apply_seed_locks(derived: list[int], locks: dict[int, int]) -> list[int]:
|
||||
"""Overlay per-index locked seeds on a derived series.
|
||||
|
||||
`locks` maps a 0-based iteration index to a pinned seed. Locked
|
||||
indices replace the derived value (masked to MAX_SAFE_SEED);
|
||||
out-of-range indices are ignored. Pure — no ComfyUI imports.
|
||||
"""
|
||||
return [
|
||||
(locks[i] & MAX_SAFE_SEED) if i in locks else seed
|
||||
for i, seed in enumerate(derived)
|
||||
]
|
||||
|
||||
|
||||
def effective_chain_seed(
|
||||
*,
|
||||
widget_seed: int,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.4.0",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.4.0"
|
||||
version = "2.7.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -171,7 +171,30 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
|
||||
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
|
||||
// across the ContextWidget + conflicts chunks; entry stays put.
|
||||
const TOTAL_LIMIT = 432 * 1024; // 432 KB
|
||||
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
|
||||
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
|
||||
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
|
||||
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
|
||||
// plus the shared components/shared/matrix-axis module the SPA matrix now
|
||||
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
|
||||
// put. Approved with the matrix-parity feature request.
|
||||
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
|
||||
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
|
||||
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
|
||||
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
|
||||
// covers both T8 and T9 landings without per-task bump friction.
|
||||
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
|
||||
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
|
||||
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
|
||||
// was tight. Minimal bump to the actual measured ceiling.
|
||||
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
|
||||
// effective base/count/strategy from wired loop). Adds preview.ts pure
|
||||
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
|
||||
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
|
||||
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
|
||||
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
|
||||
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
|
||||
const TOTAL_LIMIT = 445 * 1024; // 445 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextLoopWidget from "./ContextLoopWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptyContextLoopConfig } from "./types";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
@@ -89,3 +90,40 @@ describe("ContextLoopWidget", () => {
|
||||
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ContextLoopWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("capitalizes the bypass row label", () => {
|
||||
expect(ws().text()).toContain("Bypass loop");
|
||||
});
|
||||
it("passes the override hint to the modal only when Override Context seed is off", async () => {
|
||||
const off = ws(); // override_seed false by default → nudge to turn it on
|
||||
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { ContextLoopConfig, LoopStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,8 +22,12 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Base seed forwarded from the host ComfyUI INT widget. */
|
||||
baseSeed?: number;
|
||||
/** Iteration count forwarded from the host ComfyUI INT widget. */
|
||||
count?: number;
|
||||
}>(),
|
||||
{ nodeMode: 0 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
|
||||
@@ -46,6 +51,13 @@ const OVERRIDE_SEED_TOOLTIP =
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
|
||||
function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
function pickStrategy(s: LoopStrategy): void {
|
||||
if (props.modelValue.strategy === s) return;
|
||||
emit("update:modelValue", { ...props.modelValue, strategy: s });
|
||||
@@ -108,6 +120,14 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
Per-iteration seeds
|
||||
<span class="wp-loop__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -169,7 +189,7 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
|
||||
<div class="wp-loop__row">
|
||||
<span class="wp-loop__row-label">bypass loop</span>
|
||||
<span class="wp-loop__row-label">Bypass loop</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-loop__switch"
|
||||
@@ -181,6 +201,11 @@ function toggleTotalInternal(): void {
|
||||
<span class="wp-loop__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -289,8 +314,8 @@ function toggleTotalInternal(): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -306,8 +331,15 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
.wp-loop__pi-btn .pi { font-size: 11px; }
|
||||
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
.wp-loop__pi-btn .pi { font-size: 9px; }
|
||||
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-loop__seedbtn-fill { flex: 1; }
|
||||
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
/* Bypass-on visually dims the strategy/override/var rows since the loop
|
||||
* is effectively off. The bypass row itself stays bright. */
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -42,6 +43,7 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -65,8 +67,25 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptyContextLoopConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
|
||||
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface ContextLoopConfig {
|
||||
iteration_internal: boolean;
|
||||
/** Same idea for `$<iteration_var_name>_total`. */
|
||||
total_internal: boolean;
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
|
||||
@@ -32,6 +35,7 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +64,13 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
|
||||
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
|
||||
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ function spaUrl(): string {
|
||||
interface WildcardPayload {
|
||||
sub_categories?: string[];
|
||||
options?: Array<{ value?: string }>;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** Normalised module shape the resolution lookups consume. Unifies the
|
||||
@@ -284,6 +285,19 @@ const targetValues = computed<string[]>(() => {
|
||||
return Array.from(set);
|
||||
});
|
||||
|
||||
/** Per-axis grouping (tag_groups) for the matrix headers — orders + colours
|
||||
* the row/col tags by sub-category axis. Sibling/chain wildcard payloads
|
||||
* carry it; the preview-resolver cache does not, so a cross-context ref
|
||||
* resolved only via cache groups flat (acceptable degradation). */
|
||||
const sourceGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.source_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
const targetGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
|
||||
const sourceName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return wildcardName(pl.source_wildcard_id);
|
||||
@@ -688,6 +702,8 @@ function onSpaClick(): void {
|
||||
:target-subs="targetSubs"
|
||||
:source-name="sourceName"
|
||||
:target-name="targetName"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
|
||||
+22
-1
@@ -80,7 +80,28 @@ describe("ConstraintReattachSection", () => {
|
||||
const w = mountSection({ sourceCachedName: "colour", refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const first = w.findAll("[data-test='reattach-candidate']")[0];
|
||||
expect(first.text()).toBe("colour");
|
||||
expect(first.find("[data-test='reattach-cand-name']").text()).toBe("colour");
|
||||
});
|
||||
|
||||
it("shows each candidate's uuid + option/sub-category meta so duplicates are distinguishable", async () => {
|
||||
const rd = refData();
|
||||
// Two library wildcards share the name "colour" — only the uuid + content
|
||||
// tell them apart. Without these the picker is a coin-flip.
|
||||
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
|
||||
rd.uuidToSubCategories = new Map([["beef0001", ["warm", "cold"]], ["beef0002", ["hue"]]]);
|
||||
rd.uuidToOptionsCount = new Map([["beef0001", 2], ["beef0002", 9]]);
|
||||
const w = mountSection({ refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const c1 = w.find("[data-test-id='reattach-candidate-beef0001']");
|
||||
const c2 = w.find("[data-test-id='reattach-candidate-beef0002']");
|
||||
// The 8-hex uuid is the definitive disambiguator.
|
||||
expect(c1.text()).toContain("beef0001");
|
||||
expect(c2.text()).toContain("beef0002");
|
||||
// Option count + sub-categories give a human-readable tiebreaker.
|
||||
expect(c1.text()).toContain("2 opts");
|
||||
expect(c1.text()).toContain("warm");
|
||||
expect(c2.text()).toContain("9 opts");
|
||||
expect(c2.text()).toContain("hue");
|
||||
});
|
||||
|
||||
it("renders the community-download button only on a downloadable side", () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ const openSide = ref<Side | null>(null);
|
||||
const query = ref("");
|
||||
const picked = ref<{ uuid: string; name: string } | null>(null);
|
||||
|
||||
interface Candidate { uuid: string; name: string }
|
||||
interface Candidate { uuid: string; name: string; count: number; subCats: string[] }
|
||||
|
||||
function candidates(): Candidate[] {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
@@ -78,7 +78,12 @@ function candidates(): Candidate[] {
|
||||
for (const [uuid, name] of props.refData.uuidToName) {
|
||||
if (dead.has(uuid)) continue; // can't re-pick the dangling uuid
|
||||
if (q && !name.toLowerCase().includes(q)) continue;
|
||||
out.push({ uuid, name });
|
||||
out.push({
|
||||
uuid,
|
||||
name,
|
||||
count: props.refData.uuidToOptionsCount.get(uuid) ?? 0,
|
||||
subCats: props.refData.uuidToSubCategories.get(uuid) ?? [],
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
const ax = a.name.toLowerCase() === cached ? 0 : 1;
|
||||
@@ -87,6 +92,14 @@ function candidates(): Candidate[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** One-line disambiguator under the name: option count + declared
|
||||
* sub-categories. Same-named wildcards differ here (or, failing that, by the
|
||||
* always-shown uuid). */
|
||||
function candMeta(c: Candidate): string {
|
||||
const opts = `${c.count} opt${c.count === 1 ? "" : "s"}`;
|
||||
return c.subCats.length ? `${opts} · ${c.subCats.join(", ")}` : opts;
|
||||
}
|
||||
|
||||
function openDropdown(side: Side): void {
|
||||
openSide.value = side;
|
||||
// Empty query keeps every live candidate visible; the cached name is the
|
||||
@@ -153,7 +166,13 @@ function confirm(side: Side): void {
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-source">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
@@ -194,7 +213,13 @@ function confirm(side: Side): void {
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-target">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
@@ -273,9 +298,13 @@ function confirm(side: Side): void {
|
||||
outline: none;
|
||||
}
|
||||
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
||||
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand { display: flex; flex-direction: column; gap: 2px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.rb__cand-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.rb__cand-name { font: 11px var(--wp-font-sans); }
|
||||
.rb__cand-uuid { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
|
||||
.rb__cand-meta { font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text-dim, #6e6e7c)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rb__dropped { margin: 6px 0 0; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__blast { margin: 6px 0 0; display: flex; align-items: center; gap: 5px; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__note { margin: 6px 0 0; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
|
||||
|
||||
@@ -93,6 +93,64 @@ describe("constraint MatrixSection — layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — grouped axes", () => {
|
||||
it("renders a column band per target axis + a row header chip per source axis", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length)).toEqual(["fabric"]);
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]);
|
||||
// Cells stay keyed by tag name, so the grouped layout is reorder-safe.
|
||||
expect(w.find('[data-test="mx-cell-red-silk"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="mx-cell-blue-cotton"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("a solo source axis folds its name into the tag eyebrow (no header chip)", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "green"],
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"], shade: ["green"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]); // shade is solo
|
||||
const solo = w.find(".mx-th-row.solo");
|
||||
expect(solo.exists()).toBe(true);
|
||||
expect(solo.text()).toContain("shade");
|
||||
expect(solo.text()).toContain("green");
|
||||
});
|
||||
|
||||
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "extra"],
|
||||
targetSubs: ["cotton", "silk", "wool"],
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth", "uncategorized"]);
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length))
|
||||
.toEqual(["fabric", "uncategorized"]);
|
||||
});
|
||||
|
||||
it("stays flat (no bands / chips) when no groups are passed", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: { module: makeModule(), sourceSubs: ["red"], targetSubs: ["cotton"] },
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").length).toBe(0);
|
||||
expect(w.findAll(".mx-grp-head").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — override marker", () => {
|
||||
it("renders override marker when mode differs from library", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
|
||||
@@ -15,12 +15,19 @@
|
||||
* three (`allow` / `disabled` / missing) as runtime passthrough,
|
||||
* so the collapse is engine-equivalent.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { encodeKey } from "../../instance/keys";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import CellRulePopover from "../CellRulePopover.vue";
|
||||
import MatrixLegend from "../MatrixLegend.vue";
|
||||
import {
|
||||
groupHue as tagHue,
|
||||
groupLabel,
|
||||
isBucket,
|
||||
orderByGroups,
|
||||
toGroups,
|
||||
} from "../../../../shared/matrix-axis";
|
||||
|
||||
type Mode = "neutral" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -33,6 +40,12 @@ const props = withDefaults(
|
||||
targetSubs: readonly string[];
|
||||
sourceName?: string;
|
||||
targetName?: string;
|
||||
/** Sub-category axes (tag_groups) of the source/target wildcards. When
|
||||
* present, the matrix orders + colours its rows / cols by axis (bands on
|
||||
* the columns, header chips on the rows) — same treatment as the SPA
|
||||
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
/** Read-only recovery view: the source/target wildcard was deleted, so
|
||||
* the configured rules are shown for understanding only. Cells don't
|
||||
* open the rule popover; the grid frame + cells mute to a snapshot.
|
||||
@@ -40,7 +53,7 @@ const props = withDefaults(
|
||||
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
|
||||
stranded?: boolean;
|
||||
}>(),
|
||||
{ sourceName: "", targetName: "", stranded: false },
|
||||
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -61,6 +74,26 @@ const cellFactorOverrides = computed<Record<string, number>>(
|
||||
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
|
||||
);
|
||||
|
||||
// ── Axis grouping — order + group + colour rows/cols by sub-category axis,
|
||||
// shared with the SPA ConstraintMatrix via the matrix-axis module. ──
|
||||
const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props.sourceGroups));
|
||||
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedSources.value));
|
||||
const colGroups = computed(() => toGroups(orderedTargets.value));
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
// Measure the column band row's height so the tag row beneath it sticks at
|
||||
// the right `top` offset, independent of font sizing.
|
||||
const gridEl = ref<HTMLElement | null>(null);
|
||||
function measureBands(): void {
|
||||
const grid = gridEl.value;
|
||||
if (!grid) return;
|
||||
const band = grid.querySelector<HTMLElement>(".mx-th-band");
|
||||
grid.style.setProperty("--mx-band-h", `${band?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
watch(colGroups, () => void nextTick(measureBands));
|
||||
|
||||
/** Reads `payload.matrix[src][tgt]`. Returns null when no library rule. */
|
||||
function libCell(src: string, tgt: string): Cell | null {
|
||||
const cell = matrix.value[src]?.[tgt];
|
||||
@@ -303,12 +336,15 @@ onMounted(() => {
|
||||
document.addEventListener("keydown", onDocKeydown);
|
||||
window.addEventListener("scroll", reanchorPopover, true);
|
||||
window.addEventListener("resize", reanchorPopover);
|
||||
window.addEventListener("resize", measureBands);
|
||||
void nextTick(measureBands);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", onDocPointerDown, true);
|
||||
document.removeEventListener("keydown", onDocKeydown);
|
||||
window.removeEventListener("scroll", reanchorPopover, true);
|
||||
window.removeEventListener("resize", reanchorPopover);
|
||||
window.removeEventListener("resize", measureBands);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -335,40 +371,101 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-grid">
|
||||
<div ref="gridEl" class="mx-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="mx-corner" data-test="mx-corner">
|
||||
<th class="mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
</th>
|
||||
<th v-for="t in targetSubs" :key="t" class="mx-th-col">{{ t }}</th>
|
||||
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
|
||||
<template v-if="hasColBands">
|
||||
<th
|
||||
v-for="(grp, gi) in colGroups"
|
||||
:key="`band-${gi}`"
|
||||
class="mx-th-band"
|
||||
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
<th
|
||||
v-for="t in targetSubs"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:style="{ '--ax': 'var(--wp-constraint-target)' }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="hasColBands">
|
||||
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
|
||||
<th
|
||||
v-for="(t, ti) in grp.tags"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
|
||||
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sourceSubs" :key="s">
|
||||
<th class="mx-th-row">{{ s }}</th>
|
||||
<td v-for="t in targetSubs" :key="`${s}-${t}`" class="mx-td">
|
||||
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
|
||||
<!-- Header chip: a named multi-tag axis, OR the labelled
|
||||
uncategorized bucket (when the matrix has any named axis). A
|
||||
named solo axis folds into an eyebrow instead (below). -->
|
||||
<tr
|
||||
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
|
||||
class="mx-grp-row"
|
||||
>
|
||||
<th
|
||||
class="mx-grp-head"
|
||||
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedTargets.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</tr>
|
||||
<tr v-for="s in grp.tags" :key="s">
|
||||
<th
|
||||
class="mx-th-row"
|
||||
:class="{
|
||||
'group-start': !isBucket(grp) && s === grp.tags[0],
|
||||
solo: !isBucket(grp) && grp.tags.length === 1,
|
||||
}"
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="v">{{ s }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ s }}</span>
|
||||
</th>
|
||||
<!-- role/tabindex stay static even when stranded (mirrors the
|
||||
SPA ConstraintMatrix): the cell is still a labelled
|
||||
snapshot a screen-reader can land on; `onCellClick` early-
|
||||
returns so it's inert. -->
|
||||
<div
|
||||
:class="cellClasses(s, t)"
|
||||
:data-test="`mx-cell-${s}-${t}`"
|
||||
:aria-label="cellAriaLabel(s, t)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t)"
|
||||
@keydown.enter.prevent="onCellClick(s, t)"
|
||||
@keydown.space.prevent="onCellClick(s, t)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t)" class="factor">{{ cellFactorText(s, t) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<td v-for="t in orderedTargets" :key="`${s}-${t.tag}`" class="mx-td">
|
||||
<div
|
||||
:class="cellClasses(s, t.tag)"
|
||||
:data-test="`mx-cell-${s}-${t.tag}`"
|
||||
:aria-label="cellAriaLabel(s, t.tag)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t.tag)"
|
||||
@keydown.enter.prevent="onCellClick(s, t.tag)"
|
||||
@keydown.space.prevent="onCellClick(s, t.tag)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t.tag) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t.tag)" class="factor">{{ cellFactorText(s, t.tag) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -475,48 +572,125 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.mx-axis .arrow { font: 14px var(--wp-font-mono); line-height: 1; }
|
||||
|
||||
/* ── Grid frame ────────────────────────────────────────────── */
|
||||
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
|
||||
.mx-grid {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
/* No padding on the SCROLL container — see ConstraintMatrix: sticky pins
|
||||
* headers at the padding edge while overflow still renders scrolling cells
|
||||
* inside the padding, so padding leaves a scrolled-cell bleed strip on the
|
||||
* top/left. Content sits flush; the border + radius give the frame. */
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 4px; }
|
||||
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
|
||||
* border-collapse have a Chromium paint bug where scrolled cells bleed through
|
||||
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
|
||||
* so the layout is unchanged. */
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 0; }
|
||||
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
|
||||
* OPAQUE grid bg so scrolled cells can't bleed through them. */
|
||||
.mx-grid th,
|
||||
.mx-grid td { padding: 0; vertical-align: middle; }
|
||||
.mx-grid td { padding: 2px; vertical-align: middle; }
|
||||
.mx-grid thead th,
|
||||
.mx-grid tbody th {
|
||||
position: sticky;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
}
|
||||
.mx-grid thead th { top: 0; z-index: 2; }
|
||||
.mx-grid tbody th { left: 0; z-index: 1; }
|
||||
/* Band row pins to the top; the tag row sticks just beneath it (offset = the
|
||||
* measured band height). Corner outranks both so it stays in the angle. */
|
||||
.mx-grid thead th.mx-th-band { top: 0; z-index: 3; }
|
||||
.mx-grid thead th.mx-th-col { top: var(--mx-band-h, 0px); z-index: 2; }
|
||||
.mx-grid thead th.mx-corner { top: 0; left: 0; z-index: 5; padding: 5px 8px; }
|
||||
|
||||
.mx-corner {
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
font: 9px var(--wp-font-mono);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
padding: 0 8px !important;
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
}
|
||||
.mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
|
||||
.mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
|
||||
.mx-th-col {
|
||||
width: 64px;
|
||||
height: 28px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-target-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
|
||||
/* Column band — axis name spanning its tags, atop the tag row. */
|
||||
.mx-th-band .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 22px; padding: 0 8px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-deep, #0e1015));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
.mx-th-row {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
.mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
|
||||
/* Column tag chip — per-tag header below the band (or the only header row
|
||||
* when columns are ungrouped). */
|
||||
.mx-th-col .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; min-height: 28px; padding: 3px 2px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-source-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-deep, #0e1015));
|
||||
}
|
||||
.mx-th-col.group-start .chip { border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent); }
|
||||
|
||||
/* Row group header chip — names the axis above its tags, pinned to the left
|
||||
* edge so it stays visible during horizontal scroll. The <th> spans the row
|
||||
* but stays transparent; only the chip is visible + sticky. */
|
||||
.mx-grp-row .mx-grp-head {
|
||||
position: relative; z-index: 1; background: transparent;
|
||||
padding: 4px 0 1px; text-align: left;
|
||||
}
|
||||
.mx-grp-head .chip {
|
||||
position: sticky; left: 0;
|
||||
display: inline-flex; align-items: center;
|
||||
max-width: 220px; height: 22px; padding: 0 11px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 3px solid var(--ax);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg, #000) 55%, transparent);
|
||||
}
|
||||
.mx-grp-head--bucket .chip { border-left-style: dashed; }
|
||||
|
||||
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
|
||||
.mx-th-row .chip {
|
||||
display: flex; align-items: center; justify-content: flex-start;
|
||||
width: 96px; height: 36px; padding: 0 10px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
text-align: left;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
|
||||
}
|
||||
.mx-th-row.solo .chip {
|
||||
flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px;
|
||||
border-left-width: 3px; border-left-color: var(--ax);
|
||||
}
|
||||
.mx-th-row.solo .eye {
|
||||
max-width: 78px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: 700 7px var(--wp-font-sans);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
|
||||
}
|
||||
.mx-th-row.solo .v { font: 600 11px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.mx-td { position: relative; }
|
||||
|
||||
/* ── Body cells ────────────────────────────────────────────── */
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cacheVersion, ensure, lookup } from "../../../../../extension/preview-r
|
||||
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
|
||||
import { matches, parse, readsAs } from "@/manager/parsing/subcatFilter";
|
||||
import PairBadge from "../../../PairBadge.vue";
|
||||
import { axisHueAt } from "../../../../shared/axis-color";
|
||||
|
||||
interface OptionFull extends WildcardOption {
|
||||
value: string;
|
||||
@@ -208,20 +209,11 @@ const filteredByCategory = computed(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-axis chip colour. Each `tag_groups` axis gets a stable hue from a
|
||||
* small palette of graph-theme tokens so a tag reads with the same colour
|
||||
* here as in the pool pills. Ungrouped tags fall back to a neutral hue.
|
||||
* Returned as inline custom properties the scoped CSS consumes — keeps the
|
||||
* palette out of per-tag class explosions.
|
||||
* Per-axis chip colour via the shared `axisHueAt` palette so a tag reads
|
||||
* with the same colour here as in the pool pills. Ungrouped tags fall back
|
||||
* to a neutral hue. Returned as an inline custom property the scoped CSS
|
||||
* consumes — keeps the palette out of per-tag class explosions.
|
||||
*/
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)",
|
||||
"var(--wp-teal, #33d6c6)",
|
||||
"var(--wp-status-modified, #fb923c)",
|
||||
"var(--wp-accent2, #a970ff)",
|
||||
"var(--wp-success, #22c55e)",
|
||||
];
|
||||
|
||||
function axisOf(tag: string): number {
|
||||
// Index of the axis whose member list contains `tag`; -1 when ungrouped.
|
||||
const axes = Object.keys(props.tagGroups);
|
||||
@@ -232,9 +224,7 @@ function axisOf(tag: string): number {
|
||||
}
|
||||
|
||||
function chipStyle(tag: string): Record<string, string> {
|
||||
const idx = axisOf(tag);
|
||||
if (idx < 0) return { "--chip-hue": "var(--wp-text-dim, var(--wp-text3))" };
|
||||
return { "--chip-hue": AXIS_HUES[idx % AXIS_HUES.length] };
|
||||
return { "--chip-hue": axisHueAt(axisOf(tag)) };
|
||||
}
|
||||
const probability = computed(() =>
|
||||
probabilityFor(props.option, props.allOptions, props.instance, props.multiActive === true),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/manager/parsing/subcatFilter";
|
||||
import OptionRow from "./OptionRow.vue";
|
||||
import WpCheck from "../../../../shared/WpCheck.vue";
|
||||
import { axisHueAt, UNGROUPED_HUE } from "../../../../shared/axis-color";
|
||||
|
||||
interface OptionFull extends WildcardOption {
|
||||
value: string;
|
||||
@@ -137,19 +138,11 @@ function onPillClick(tag: string): void {
|
||||
emit("update", patchInstance(props.module, "category_filter", expr));
|
||||
}
|
||||
|
||||
/** Per-axis pill colour — mirrors OptionRow's CATEGORY chip hues so a tag
|
||||
* reads with the same colour in both surfaces. */
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)",
|
||||
"var(--wp-teal, #33d6c6)",
|
||||
"var(--wp-status-modified, #fb923c)",
|
||||
"var(--wp-accent2, #a970ff)",
|
||||
"var(--wp-success, #22c55e)",
|
||||
];
|
||||
/** Per-axis pill colour via the shared `axisHueAt` palette — mirrors
|
||||
* OptionRow's chip hues so a tag reads the same in both surfaces. */
|
||||
function axisHue(axis: string): string {
|
||||
if (axis === OTHER) return "var(--wp-text-dim, var(--wp-text3))";
|
||||
const idx = pillGroups.value.findIndex((g) => g.axis === axis);
|
||||
return AXIS_HUES[(idx < 0 ? 0 : idx) % AXIS_HUES.length];
|
||||
if (axis === OTHER) return UNGROUPED_HUE;
|
||||
return axisHueAt(pillGroups.value.findIndex((g) => g.axis === axis));
|
||||
}
|
||||
|
||||
function categoryOptionCount(cat: string): number {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import SeedListWidget from "./SeedListWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptySeedListConfig } from "./types";
|
||||
|
||||
describe("SeedListWidget", () => {
|
||||
@@ -142,3 +143,46 @@ describe("SeedListWidget", () => {
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra = {}) {
|
||||
return mount(SeedListWidget, {
|
||||
props: { modelValue: { ...emptySeedListConfig(), ...extra }, baseSeed: 7, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("SeedListWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="seedlist-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "0": 9, "2": 8, "3": 1 } }).find('[data-test="seedlist-seeds-badge"]').text()).toMatch(/3 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="seedlist-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("previewStrategy is passed to modal when provided", async () => {
|
||||
const wr = mount(SeedListWidget, {
|
||||
props: { modelValue: emptySeedListConfig(), baseSeed: 7, count: 4, previewStrategy: "hash_index" },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).props("strategy")).toBe("hash_index");
|
||||
});
|
||||
it("passes an override hint to the modal only when override_seed is on", async () => {
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const off = ws(); // override_seed false by default
|
||||
await off.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* host glue (`src/widgets/seed_list.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { SeedListConfig, SeedListStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,8 +22,19 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Effective base seed for the modal preview (already resolved by the host
|
||||
* glue — either local base_seed or the wired loop's seed, depending on
|
||||
* override_seed + whether a loop is connected). */
|
||||
baseSeed?: number;
|
||||
/** Effective iteration count for the modal preview (resolved by host glue). */
|
||||
count?: number;
|
||||
/** Effective strategy for the modal preview. When supplied (loop wired +
|
||||
* override_strategy on), the modal uses this instead of modelValue.strategy.
|
||||
* The strategy CHIPS still reflect modelValue.strategy (the local config).
|
||||
* Undefined = use modelValue.strategy (no loop / override off). */
|
||||
previewStrategy?: SeedListStrategy;
|
||||
}>(),
|
||||
{ nodeMode: 0 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
|
||||
@@ -55,6 +67,13 @@ const STRATEGY_LOCKED_TOOLTIP =
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
|
||||
function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
/** True while strategy comes from the upstream loop_config wire.
|
||||
* Mirrors what `setStockWidgetDisabled("count", ...)` does for the count
|
||||
* stock widget in the host glue — chips can't live as a stock widget so
|
||||
@@ -125,6 +144,14 @@ function toggleOverrideStrategy(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-seedlist__seedbtn" data-test="seedlist-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-seedlist__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
Per-iteration seeds
|
||||
<span class="wp-seedlist__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<svg class="wp-seedlist__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<div class="wp-seedlist__row" :title="OVERRIDE_SEED_TOOLTIP">
|
||||
<span class="wp-seedlist__row-label">Override base seed from loop</span>
|
||||
<button
|
||||
@@ -172,6 +199,10 @@ function toggleOverrideStrategy(): void {
|
||||
<span class="wp-seedlist__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -263,4 +294,11 @@ function toggleOverrideStrategy(): void {
|
||||
/* Mute / bypass dim — same convention as WP_VarTo* and WP_ContextLoop. */
|
||||
.wp-seedlist--muted { opacity: 0.45; pointer-events: none; }
|
||||
.wp-seedlist--bypassed { opacity: 0.65; }
|
||||
|
||||
.wp-seedlist__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-seedlist__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-seedlist__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-seedlist__seedbtn-fill { flex: 1; }
|
||||
.wp-seedlist__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-seedlist__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSeedListPreview } from "./preview";
|
||||
|
||||
const local = { base: 5, count: 3, strategy: "sequential" as const };
|
||||
const loop = { base: 100, count: 8, strategy: "hash_index" as const };
|
||||
const NONE = { override_seed: false, override_count: false, override_strategy: false };
|
||||
|
||||
describe("resolveSeedListPreview", () => {
|
||||
it("no loop wired → all local (even if toggles on)", () => {
|
||||
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, null))
|
||||
.toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
|
||||
});
|
||||
it("no overrides → all local", () => {
|
||||
expect(resolveSeedListPreview(local, NONE, loop)).toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
|
||||
});
|
||||
it("override_count only → loop count, local base+strategy", () => {
|
||||
expect(resolveSeedListPreview(local, { ...NONE, override_count: true }, loop))
|
||||
.toEqual({ baseSeed: 5, count: 8, strategy: "sequential" });
|
||||
});
|
||||
it("all overrides → all loop", () => {
|
||||
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, loop))
|
||||
.toEqual({ baseSeed: 100, count: 8, strategy: "hash_index" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SeedListStrategy } from "./types";
|
||||
|
||||
export interface SeriesParams { base: number; count: number; strategy: SeedListStrategy }
|
||||
export interface OverrideFlags { override_seed: boolean; override_count: boolean; override_strategy: boolean }
|
||||
|
||||
/** Mirror of wp_nodes/seed_list.py::_resolve_config for the modal preview:
|
||||
* an override flag pulls from the wired loop ONLY when a loop is present;
|
||||
* otherwise the local widget value wins. */
|
||||
export function resolveSeedListPreview(
|
||||
local: SeriesParams,
|
||||
flags: OverrideFlags,
|
||||
loop: SeriesParams | null,
|
||||
): { baseSeed: number; count: number; strategy: SeedListStrategy } {
|
||||
return {
|
||||
baseSeed: flags.override_seed && loop ? loop.base : local.base,
|
||||
count: flags.override_count && loop ? loop.count : local.count,
|
||||
strategy: flags.override_strategy && loop ? loop.strategy : local.strategy,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: true,
|
||||
override_count: true,
|
||||
override_strategy: true,
|
||||
seed_locks: {},
|
||||
};
|
||||
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -38,6 +39,7 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: false,
|
||||
override_count: true,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -64,6 +66,7 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: false,
|
||||
override_count: false,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,8 +92,25 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: false,
|
||||
override_count: true,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
const round = parseSeedListConfig(serializeSeedListConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptySeedListConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptySeedListConfig(), seed_locks: { "0": 7 } };
|
||||
expect(parseSeedListConfig(serializeSeedListConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface SeedListConfig {
|
||||
override_count: boolean;
|
||||
/** Take `strategy` from the wired loop_config. */
|
||||
override_strategy: boolean;
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<SeedListStrategy>(["hash_index", "sequential", "prime_stride"]);
|
||||
@@ -28,6 +31,7 @@ export function emptySeedListConfig(): SeedListConfig {
|
||||
override_seed: false,
|
||||
override_count: false,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +73,13 @@ export function parseSeedListConfig(raw: string | null | undefined): SeedListCon
|
||||
if (!hasNewCount) out.override_count = legacy;
|
||||
if (!hasNewStrategy) out.override_strategy = legacy;
|
||||
}
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
/** Per-iteration seed modal — shared by the Context Loop + Seed List
|
||||
* node widgets. Computes the derived list locally (seed-derive mirror)
|
||||
* and overlays seed_locks; edits re-emit the full merged lock map. */
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { deriveLoopSeeds, parseSeedLocks, type SeedStrategy } from "./seed-derive";
|
||||
import SeedLockRow from "./SeedLockRow.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
nodeName: string;
|
||||
baseSeed: number;
|
||||
count: number;
|
||||
strategy: SeedStrategy;
|
||||
seedLocks: Record<string, number>;
|
||||
/** Yellow override notice text; empty/undefined hides it. Context Loop
|
||||
* shows it when "Override Context seed" is OFF (a nudge); Seed List shows
|
||||
* it when "Override base seed from loop" is ON (base comes from the loop). */
|
||||
overrideHint?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
|
||||
|
||||
const derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
|
||||
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
|
||||
const copied = ref(false);
|
||||
const pasted = ref(false);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pastedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** Locked indices beyond the current count — shown dimmed below the active
|
||||
* rows so a forgotten out-of-range lock is visible (it re-applies if count
|
||||
* grows back). */
|
||||
const inactiveLocks = computed(() => {
|
||||
const active = derived.value.length;
|
||||
return Object.keys(props.seedLocks)
|
||||
.map(Number)
|
||||
.filter((i) => Number.isInteger(i) && i >= active)
|
||||
.sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
function onRow(p: { index: number; seed: number | null }): void {
|
||||
const next = { ...props.seedLocks };
|
||||
if (p.seed == null) delete next[String(p.index)];
|
||||
else next[String(p.index)] = p.seed;
|
||||
emit("update:seedLocks", next);
|
||||
}
|
||||
function lockAll(): void {
|
||||
const next: Record<string, number> = {};
|
||||
derived.value.forEach((s, i) => { next[String(i)] = s; });
|
||||
emit("update:seedLocks", next);
|
||||
}
|
||||
function unlockAll(): void { emit("update:seedLocks", {}); }
|
||||
/** Build the `#N: seed` clipboard text. lockedOnly → just the locked rows
|
||||
* (round-trips through Paste to re-lock exactly those); else every iteration
|
||||
* with locks overlaid. */
|
||||
function copyText(lockedOnly: boolean): string {
|
||||
if (lockedOnly) {
|
||||
return Object.keys(props.seedLocks)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => `#${i + 1}: ${props.seedLocks[String(i)]}`)
|
||||
.join("\n");
|
||||
}
|
||||
return derived.value.map((s, i) => `#${i + 1}: ${props.seedLocks[String(i)] ?? s}`).join("\n");
|
||||
}
|
||||
function copyAll(ev: MouseEvent): void {
|
||||
void navigator.clipboard?.writeText(copyText(ev.altKey)); // Alt-click → locked seeds only
|
||||
copied.value = true;
|
||||
if (copiedTimer) clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => { copied.value = false; }, 1500);
|
||||
}
|
||||
/** Read the clipboard, parse the `#N: seed` format Copy produces, and REPLACE
|
||||
* the whole lock map with it. No-op on empty/garbage so a stray paste can't
|
||||
* wipe existing locks. Pasted indices beyond count land as inactive rows. */
|
||||
async function pasteAll(): Promise<void> {
|
||||
let text = "";
|
||||
try { text = (await navigator.clipboard?.readText()) ?? ""; } catch { text = ""; }
|
||||
const next = parseSeedLocks(text);
|
||||
if (Object.keys(next).length === 0) return;
|
||||
emit("update:seedLocks", next);
|
||||
pasted.value = true;
|
||||
if (pastedTimer) clearTimeout(pastedTimer);
|
||||
pastedTimer = setTimeout(() => { pasted.value = false; }, 1500);
|
||||
}
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") { ev.preventDefault(); emit("close"); }
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
if (copiedTimer) clearTimeout(copiedTimer);
|
||||
if (pastedTimer) clearTimeout(pastedTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="sm-overlay" @click="emit('close')">
|
||||
<div class="sm" role="dialog" aria-modal="true" data-test="seed-modal" @click.stop>
|
||||
<div class="sm__head">
|
||||
<span class="sm__head-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
<div class="sm__title-block">
|
||||
<div class="sm__title-row"><span class="sm__name">{{ nodeName }}</span><span class="sm__chip">Iteration Seeds</span></div>
|
||||
<div class="sm__sub">base {{ baseSeed }} · {{ strategy }} · {{ Math.max(1, count) }} iterations</div>
|
||||
</div>
|
||||
<button class="sm__close" data-test="seed-modal-close" aria-label="Close" @click="emit('close')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 5l14 14M19 5L5 19" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm__bar">
|
||||
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
|
||||
<span class="sm__strat">{{ strategy }}</span>
|
||||
<span v-if="lockedCount" class="sm__lockcount" data-test="mx-seed-lockcount">{{ lockedCount }} locked</span>
|
||||
<span class="sm__bar-spacer" />
|
||||
<button class="ghost" data-test="mx-seed-lockall" @click="lockAll">Lock all</button>
|
||||
<button class="ghost" data-test="mx-seed-unlockall" @click="unlockAll">Unlock all</button>
|
||||
<button class="ghost" :class="{ 'ghost--copied': copied }" data-test="mx-seed-copy"
|
||||
title="Copy all seeds · Alt-click to copy only the locked ones" @click="copyAll">
|
||||
<svg v-if="copied" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
|
||||
{{ copied ? "Copied" : "Copy" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="overrideHint" class="sm__hint" data-test="mx-seed-hint">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M12 11v5" stroke-linecap="round" /><circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" /></svg>
|
||||
<span>{{ overrideHint }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sm__list">
|
||||
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
|
||||
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
|
||||
:seed="seedLocks[String(i)] ?? null" @update="onRow" />
|
||||
<template v-if="inactiveLocks.length">
|
||||
<div class="sm__inactive-label" data-test="mx-seed-inactive">
|
||||
Inactive · won't apply at count {{ derived.length }}
|
||||
</div>
|
||||
<SeedLockRow v-for="i in inactiveLocks" :key="`inact-${i}`" :index="i"
|
||||
:derived="seedLocks[String(i)]" :locked="true" :seed="seedLocks[String(i)]"
|
||||
:inactive="true" @update="onRow" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sm__foot">
|
||||
<span class="sm__foot-hint"><kbd>Esc</kbd> to close · locks save with the node</span>
|
||||
<span class="sm__foot-spacer" />
|
||||
<button class="ghost" :class="{ 'ghost--copied': pasted }" data-test="mx-seed-paste"
|
||||
title="Replace all locks from copied text" @click="pasteAll">
|
||||
<svg v-if="pasted" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2.5" width="8" height="4" rx="1" /><path d="M8 4.5H6a2 2 0 0 0-2 2V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6.5a2 2 0 0 0-2-2h-2" /></svg>
|
||||
{{ pasted ? "Pasted" : "Paste" }}
|
||||
</button>
|
||||
<button class="btn" data-test="seed-modal-done" @click="emit('close')">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./theme.css";
|
||||
.sm-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0,0,0,.62); font-family: var(--wp-font-sans, sans-serif); }
|
||||
.sm { width: 486px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: 6px; overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.55); }
|
||||
.sm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: linear-gradient(180deg, color-mix(in srgb, var(--wp-accent) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); }
|
||||
.sm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
|
||||
.sm__title-block { flex: 1; min-width: 0; }
|
||||
.sm__title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.sm__name { font: 600 13px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.sm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: 2px; background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
|
||||
.sm__sub { font: 400 10px var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
|
||||
.sm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; display: flex; }
|
||||
.sm__close:hover { color: var(--wp-text); }
|
||||
.sm__bar { display: flex; align-items: center; gap: 8px; padding: 9px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); }
|
||||
.sm__count { font: 600 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.sm__strat { font: 600 9px var(--wp-font-mono); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); }
|
||||
.sm__lockcount { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.sm__bar-spacer { flex: 1; }
|
||||
.ghost { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 3px; border: 1px solid var(--wp-border); background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
.ghost:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
|
||||
.ghost--copied, .ghost--copied:hover { border-color: var(--wp-green, #6bc96f); color: var(--wp-green, #6bc96f); }
|
||||
.sm__hint { display: flex; align-items: center; gap: 7px; padding: 7px 14px; flex-shrink: 0; font: 11px var(--wp-font-sans); color: color-mix(in srgb, var(--wp-amber, #fbbf24) 75%, var(--wp-text-muted)); background: color-mix(in srgb, var(--wp-amber, #fbbf24) 9%, transparent); border-bottom: 1px solid var(--wp-border); }
|
||||
.sm__hint svg { flex-shrink: 0; color: var(--wp-amber, #fbbf24); }
|
||||
.sm__list { overflow-y: auto; padding: 6px; flex: 1; min-height: 0; }
|
||||
.sm__inactive-label { padding: 8px 8px 4px; margin-top: 4px; border-top: 1px dashed var(--wp-border); font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .08em; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.sm__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); }
|
||||
.sm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.sm__foot-hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; }
|
||||
.sm__foot-spacer { flex: 1; }
|
||||
.btn { padding: 6px 14px; border: 1px solid var(--wp-accent); border-radius: 3px; background: var(--wp-accent); color: #fff; font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
/** One per-iteration row in SeedListModal — a per-row mirror of the
|
||||
* module SeedLockControls. Unlocked shows the derived seed; locking
|
||||
* captures it; locked exposes a seed input + ±spinners + roll. */
|
||||
const props = defineProps<{
|
||||
index: number; // 0-based
|
||||
derived: number; // computed seed for this index (preview when unlocked)
|
||||
locked: boolean;
|
||||
seed: number | null; // locked seed value when locked
|
||||
/** Locked index beyond the current count — dimmed but still editable;
|
||||
* re-activates if count grows back. */
|
||||
inactive?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
|
||||
|
||||
const SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
|
||||
function randomSeed(): number {
|
||||
const hi = Math.floor(Math.random() * 0x40000); // 18 bits
|
||||
const lo = Math.floor(Math.random() * 0x100000000); // 32 bits
|
||||
return (hi * 0x100000000 + lo) % (SEED_MAX + 1);
|
||||
}
|
||||
function set(seed: number | null) { emit("update", { index: props.index, seed }); }
|
||||
function onLock() { set(props.locked ? null : props.derived); }
|
||||
function onInput(ev: Event) {
|
||||
const n = Number((ev.target as HTMLInputElement).value.trim());
|
||||
if (Number.isFinite(n) && Number.isInteger(n)) set(n);
|
||||
}
|
||||
function bump(d: 1 | -1) {
|
||||
if (!props.locked || props.seed == null) return;
|
||||
set(Math.max(0, Math.min(SEED_MAX, props.seed + d)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive }">
|
||||
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
|
||||
<button type="button" class="toggle" :class="{ 'toggle--on': locked }"
|
||||
data-test="seedrow-lock" role="switch" :aria-checked="locked" @click="onLock">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="11" width="14" height="9" rx="1.5" /><path :d="locked ? 'M8 11V8a4 4 0 0 1 8 0v3' : 'M8 11V8a4 4 0 0 1 8 0'" />
|
||||
</svg>
|
||||
{{ locked ? "Locked" : "Lock" }}
|
||||
</button>
|
||||
<span class="srow__fill" />
|
||||
<span v-if="!locked" class="derived">
|
||||
<span class="derived__val" data-test="seedrow-derived">{{ derived }}</span>
|
||||
<span class="derived__tag">derived</span>
|
||||
</span>
|
||||
<span v-else class="seed-controls">
|
||||
<span class="seed-input-wrap">
|
||||
<input class="seed-input" data-test="seedrow-input" type="number" step="1"
|
||||
:value="seed ?? ''" aria-label="Locked seed" @input="onInput" />
|
||||
<span class="seed-spin">
|
||||
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Increase" @click="bump(1)">
|
||||
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 5 L4 0 L8 5 Z" fill="currentColor" /></svg></button>
|
||||
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Decrease" @click="bump(-1)">
|
||||
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 0 L4 5 L8 0 Z" fill="currentColor" /></svg></button>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="seed-roll" data-test="seedrow-roll" title="Random seed"
|
||||
aria-label="Random seed" @click="set(randomSeed())">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 4v5h-5" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.srow { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 5px; }
|
||||
.srow--locked { background: rgba(99,102,241,0.07); box-shadow: inset 2px 0 0 var(--wp-accent, #6366f1); }
|
||||
/* out-of-range lock: dimmed ("deemed down") but still interactive — hover lifts it. */
|
||||
.srow--inactive { opacity: .5; }
|
||||
.srow--inactive:hover { opacity: .85; }
|
||||
.srow__idx { flex-shrink: 0; width: 30px; text-align: right; font: 600 11px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #7a7d88); }
|
||||
.srow--locked .srow__idx { color: var(--wp-accent-text, #c4b5fd); }
|
||||
.srow__fill { flex: 1; }
|
||||
.toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid var(--wp-border); border-radius: 3px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); cursor: pointer; background: var(--wp-bg-deep, var(--wp-bg)); flex-shrink: 0; }
|
||||
.toggle:hover { border-color: var(--wp-border-strong, var(--wp-border2)); }
|
||||
.toggle svg { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
|
||||
.toggle--on svg { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.derived { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.derived__val { font: 11px var(--wp-font-mono, monospace); color: var(--wp-text4, #888); }
|
||||
.derived__tag { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .07em; color: var(--wp-text-dim, var(--wp-text3)); border: 1px solid var(--wp-border); border-radius: 3px; padding: 1px 5px; }
|
||||
.seed-controls { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.seed-input-wrap { display: inline-flex; align-items: stretch; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; height: 24px; width: 178px; overflow: hidden; }
|
||||
.seed-input-wrap:focus-within { border-color: var(--wp-accent); }
|
||||
.seed-input { flex: 1; background: transparent; border: 0; padding: 0 7px; font: 11px var(--wp-font-mono, monospace); color: var(--wp-text); text-align: right; min-width: 0; -moz-appearance: textfield; }
|
||||
.seed-input::-webkit-outer-spin-button, .seed-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.seed-input:focus { outline: none; }
|
||||
.seed-spin { display: flex; flex-direction: column; width: 14px; flex-shrink: 0; border-left: 1px solid var(--wp-border); background: rgba(99,102,241,.04); }
|
||||
.seed-spin-btn { flex: 1; display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; line-height: 0; }
|
||||
.seed-spin-btn + .seed-spin-btn { border-top: 1px solid var(--wp-border); }
|
||||
.seed-spin-btn:hover { color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); }
|
||||
.seed-roll { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 2px 4px; display: flex; }
|
||||
.seed-roll:hover { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import SeedListModal from "../SeedListModal.vue";
|
||||
|
||||
// jsdom has no real clipboard; defineProperty so it survives a read-only getter.
|
||||
// Returns the array capturing every writeText payload.
|
||||
function stubClipboard(readValue = "") {
|
||||
const writes: string[] = [];
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
readText: () => Promise.resolve(readValue),
|
||||
writeText: (t: string) => { writes.push(t); return Promise.resolve(); },
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
function modal(props = {}) {
|
||||
return mount(SeedListModal, {
|
||||
props: { nodeName: "WP Context Loop", baseSeed: 42, count: 4, strategy: "sequential",
|
||||
seedLocks: { "1": 999 }, overrideHint: "Test hint.", ...props },
|
||||
attachTo: document.body,
|
||||
// The modal body renders inside `<Teleport to="body">`; stubbing teleport
|
||||
// renders the children in place so the wrapper's `find()` reaches them
|
||||
// (repo convention — see RemapRefPopup.test.ts, ConfirmDialog.test.ts).
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
// ES2020 lib — no Array.prototype.at; read the last emit by index.
|
||||
function lastEmit(w: ReturnType<typeof modal>) {
|
||||
const ev = w.emitted("update:seedLocks")!;
|
||||
return ev[ev.length - 1][0];
|
||||
}
|
||||
|
||||
describe("SeedListModal", () => {
|
||||
it("renders one row per iteration (count)", () => {
|
||||
expect(modal().findAll('[data-test="seedrow-idx"]')).toHaveLength(4);
|
||||
});
|
||||
it("shows the locked-count pill", () => {
|
||||
expect(modal().find('[data-test="mx-seed-lockcount"]').text()).toMatch(/1 locked/i);
|
||||
});
|
||||
it("shows the override hint with the provided text; hidden when empty", () => {
|
||||
const shown = modal({ overrideHint: "Heads up: override is on." });
|
||||
expect(shown.find('[data-test="mx-seed-hint"]').exists()).toBe(true);
|
||||
expect(shown.find('[data-test="mx-seed-hint"]').text()).toContain("Heads up: override is on.");
|
||||
expect(modal({ overrideHint: "" }).find('[data-test="mx-seed-hint"]').exists()).toBe(false);
|
||||
});
|
||||
it("a row update re-emits update:seedLocks with the merged map", async () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
await w.findAll('[data-test="seedrow-lock"]')[0].trigger("click"); // lock #1 (index 0), sequential derived = 42
|
||||
expect(lastEmit(w)).toEqual({ "0": 42 });
|
||||
});
|
||||
it("Lock all locks every iteration to its derived seed", async () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
await w.find('[data-test="mx-seed-lockall"]').trigger("click");
|
||||
expect(lastEmit(w)).toEqual({ "0": 42, "1": 43, "2": 44, "3": 45 });
|
||||
});
|
||||
it("Unlock all emits an empty map", async () => {
|
||||
const w = modal({ seedLocks: { "1": 999 } });
|
||||
await w.find('[data-test="mx-seed-unlockall"]').trigger("click");
|
||||
expect(lastEmit(w)).toEqual({});
|
||||
});
|
||||
it("Copy flashes a 'Copied' indicator on click", async () => {
|
||||
const w = modal();
|
||||
const btn = () => w.find('[data-test="mx-seed-copy"]');
|
||||
expect(btn().classes()).not.toContain("ghost--copied");
|
||||
await btn().trigger("click");
|
||||
expect(btn().classes()).toContain("ghost--copied");
|
||||
expect(btn().text()).toContain("Copied");
|
||||
w.unmount(); // clears the 1.5s reset timer via onBeforeUnmount
|
||||
});
|
||||
it("renders locks beyond count as a dimmed inactive group", () => {
|
||||
const w = modal({ count: 3, seedLocks: { "1": 11, "5": 55 } });
|
||||
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(true);
|
||||
// 3 active rows (#1-#3) + the out-of-range lock shown as #6
|
||||
expect(w.findAll('[data-test="seedrow-idx"]').map((n) => n.text())).toEqual(["#1", "#2", "#3", "#6"]);
|
||||
});
|
||||
it("no inactive group when every lock is within count", () => {
|
||||
const w = modal({ count: 3, seedLocks: { "1": 11 } });
|
||||
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(false);
|
||||
expect(w.findAll('[data-test="seedrow-idx"]')).toHaveLength(3);
|
||||
});
|
||||
it("Paste replaces the whole lock map from the copied text", async () => {
|
||||
stubClipboard("#2: 77\n#3: 88"); // 1-based display → indices 1 and 2
|
||||
const w = modal({ seedLocks: { "0": 1 } }); // existing lock must be replaced, not merged
|
||||
await w.find('[data-test="mx-seed-paste"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(lastEmit(w)).toEqual({ "1": 77, "2": 88 });
|
||||
expect(w.find('[data-test="mx-seed-paste"]').text()).toContain("Pasted");
|
||||
});
|
||||
it("Paste with no parseable lines leaves locks untouched", async () => {
|
||||
stubClipboard("garbage\nnot a seed line");
|
||||
const w = modal({ seedLocks: { "0": 1 } });
|
||||
await w.find('[data-test="mx-seed-paste"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(w.emitted("update:seedLocks")).toBeUndefined();
|
||||
});
|
||||
it("plain-click Copy writes every seed with locks overlaid", async () => {
|
||||
const writes = stubClipboard();
|
||||
const w = modal({ count: 3, seedLocks: { "1": 999 } }); // sequential base 42 → 42,43,44
|
||||
await w.find('[data-test="mx-seed-copy"]').trigger("click");
|
||||
expect(writes[writes.length - 1]).toBe("#1: 42\n#2: 999\n#3: 44");
|
||||
});
|
||||
it("Alt+click Copy writes only the locked seeds", async () => {
|
||||
const writes = stubClipboard();
|
||||
const w = modal({ count: 4, seedLocks: { "1": 999 } });
|
||||
await w.find('[data-test="mx-seed-copy"]').trigger("click", { altKey: true });
|
||||
expect(writes[writes.length - 1]).toBe("#2: 999");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import SeedLockRow from "../SeedLockRow.vue";
|
||||
|
||||
function row(props = {}) {
|
||||
return mount(SeedLockRow, { props: { index: 0, derived: 12345, locked: false, seed: null, ...props } });
|
||||
}
|
||||
|
||||
describe("SeedLockRow", () => {
|
||||
it("unlocked: shows derived value + a Lock toggle, no input", () => {
|
||||
const w = row();
|
||||
expect(w.find('[data-test="seedrow-derived"]').text()).toContain("12345");
|
||||
expect(w.find('[data-test="seedrow-input"]').exists()).toBe(false);
|
||||
});
|
||||
it("locking emits update with the derived value captured", async () => {
|
||||
const w = row();
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 12345 });
|
||||
});
|
||||
it("unlocking emits update with null", async () => {
|
||||
const w = row({ locked: true, seed: 999 });
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
|
||||
});
|
||||
it("editing the seed input emits update with the typed value", async () => {
|
||||
const w = row({ locked: true, seed: 999 });
|
||||
await w.find('[data-test="seedrow-input"]').setValue("4242");
|
||||
const updates = w.emitted("update")!;
|
||||
expect(updates[updates.length - 1][0]).toEqual({ index: 0, seed: 4242 });
|
||||
});
|
||||
it("displays #N (1-based) from the 0-based index", () => {
|
||||
expect(row({ index: 2 }).find('[data-test="seedrow-idx"]').text()).toBe("#3");
|
||||
});
|
||||
it("inactive dims the row but stays interactive (unlock still emits)", async () => {
|
||||
const w = row({ locked: true, seed: 999, inactive: true });
|
||||
expect(w.find(".srow").classes()).toContain("srow--inactive");
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import corpus from "../../../../tests/fixtures/seed-derive-corpus.json";
|
||||
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks } from "../seed-derive";
|
||||
|
||||
describe("seed-derive (Py≡TS parity)", () => {
|
||||
it("matches the engine corpus for every (base, count, strategy)", () => {
|
||||
for (const c of corpus.derive) {
|
||||
expect(deriveLoopSeeds(c.base, c.count, c.strategy as never)).toEqual(c.derived);
|
||||
}
|
||||
});
|
||||
it("applySeedLocks matches the engine", () => {
|
||||
const a = corpus.applyLocks;
|
||||
const locks: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(a.locks)) locks[Number(k)] = v as number;
|
||||
expect(applySeedLocks(a.derived, locks)).toEqual(a.locked);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSeedLocks (Paste — inverse of the modal copy format)", () => {
|
||||
it("parses #N: seed lines into a 0-based lock map", () => {
|
||||
expect(parseSeedLocks("#1: 42\n#2: 43\n#3: 44")).toEqual({ "0": 42, "1": 43, "2": 44 });
|
||||
});
|
||||
it("ignores blank, malformed, and #0 lines; tolerates stray whitespace", () => {
|
||||
expect(parseSeedLocks("\n#2: 7\ngarbage\n #4 : 9 \n#0: 5\n#: x")).toEqual({ "1": 7, "3": 9 });
|
||||
});
|
||||
it("returns an empty map when nothing parses", () => {
|
||||
expect(parseSeedLocks("nope\n123\nfoo: bar")).toEqual({});
|
||||
});
|
||||
it("masks values to 50 bits — negatives normalize like the engine", () => {
|
||||
expect(parseSeedLocks("#1: -1")).toEqual({ "0": (2 ** 50) - 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { axisHueAt, UNGROUPED_HUE } from "./axis-color";
|
||||
|
||||
describe("axisHueAt", () => {
|
||||
it("returns the neutral hue for ungrouped tags", () => {
|
||||
expect(axisHueAt(-1)).toBe(UNGROUPED_HUE);
|
||||
});
|
||||
|
||||
it("gives every group a distinct colour — no two axes share a hue", () => {
|
||||
const hues = Array.from({ length: 24 }, (_, i) => axisHueAt(i));
|
||||
expect(new Set(hues).size).toBe(24);
|
||||
});
|
||||
|
||||
it("has no duplicate in the curated palette (the old two-purples bug)", () => {
|
||||
const curated = Array.from({ length: 8 }, (_, i) => axisHueAt(i));
|
||||
expect(new Set(curated).size).toBe(curated.length);
|
||||
// The legacy violet that collided with purple must be gone.
|
||||
expect(curated).not.toContain("var(--wp-accent2, #a970ff)");
|
||||
});
|
||||
|
||||
it("generates an hsl hue past the curated palette", () => {
|
||||
expect(axisHueAt(8)).toMatch(/^hsl\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Per-axis sub-category colours — one source of truth for the wildcard
|
||||
* editor pills, the option-row tag chips, and the pool palette, so a tag
|
||||
* reads with the same colour on every surface.
|
||||
*
|
||||
* Each sub-category GROUP (axis) gets a distinct hue by its position among
|
||||
* its siblings. The old per-component copies shared TWO failings that both
|
||||
* produced "two groups, same colour": the palette held two near-identical
|
||||
* purples (`#a78bfa` and `#a970ff`), and assignment wrapped with
|
||||
* `% length`, repeating once there were more groups than hues. This palette
|
||||
* is de-duplicated and ordered for maximum separation between consecutive
|
||||
* axes; past it, a golden-angle hue keeps every additional group distinct.
|
||||
*/
|
||||
|
||||
/** Neutral hue for ungrouped / "other" tags. */
|
||||
export const UNGROUPED_HUE = "var(--wp-text-dim, var(--wp-text3))";
|
||||
|
||||
/** Curated distinct hues (no two similar), ordered so consecutive axes land
|
||||
* far apart on the wheel. Theme tokens where they exist, hex otherwise. */
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)", // purple
|
||||
"var(--wp-teal, #33d6c6)", // teal
|
||||
"var(--wp-status-modified, #fb923c)", // orange
|
||||
"#60a5fa", // blue
|
||||
"var(--wp-success, #22c55e)", // green
|
||||
"#fb7185", // rose
|
||||
"#fbbf24", // amber
|
||||
"#e879f9", // magenta
|
||||
];
|
||||
|
||||
/**
|
||||
* Distinct, non-repeating hue for the sub-category group (axis) at `index`
|
||||
* among its siblings. `index < 0` (ungrouped) → the neutral token. Past the
|
||||
* curated palette, a golden-angle hue keeps additional groups distinct
|
||||
* rather than wrapping back onto an earlier colour.
|
||||
*/
|
||||
export function axisHueAt(index: number): string {
|
||||
if (index < 0) return UNGROUPED_HUE;
|
||||
if (index < AXIS_HUES.length) return AXIS_HUES[index];
|
||||
const hue = ((index * 137.508) % 360).toFixed(1);
|
||||
return `hsl(${hue} 68% 66%)`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Constraint-matrix axis grouping — the pure logic shared by both matrix
|
||||
* surfaces (the SPA `ConstraintMatrix.vue` library editor and the canvas
|
||||
* `MatrixSection.vue` instance modal). Keeping it here means the two grids
|
||||
* order, group, label and colour their axes identically; only their template
|
||||
* + CSS differ.
|
||||
*
|
||||
* A wildcard's `tag_groups` (axis name → member sub-categories) drives the
|
||||
* layout: tags are ordered by axis, each axis becomes a coloured band
|
||||
* (columns) or pinned header chip (rows), and the leftover run of tags with
|
||||
* no axis becomes the labelled "uncategorized" bucket instead of a blank.
|
||||
*/
|
||||
import { axisHueAt } from "./axis-color";
|
||||
|
||||
/** One tag stamped with its axis + whether it opens a new group. Cell lookups
|
||||
* stay keyed by tag name, so reordering for display is safe. */
|
||||
export interface TagMeta {
|
||||
tag: string;
|
||||
axisIndex: number; // -1 = ungrouped
|
||||
axisName: string;
|
||||
groupStart: boolean; // first tag of its axis
|
||||
}
|
||||
|
||||
/** A contiguous run of tags belonging to one axis (or the trailing ungrouped
|
||||
* run). Columns render one band per group; row groups with >1 tag get a
|
||||
* header chip, solo named groups fold the name into the tag. */
|
||||
export interface AxisGroup {
|
||||
axisIndex: number; // -1 = ungrouped
|
||||
axisName: string;
|
||||
tags: string[];
|
||||
grouped: boolean;
|
||||
}
|
||||
|
||||
/** Order `tags` by axis (grouped first, in axis order; ungrouped last) and
|
||||
* stamp each with its axis index + whether it opens a new group. */
|
||||
export function orderByGroups(tags: string[], groups: Record<string, string[]>): TagMeta[] {
|
||||
const out: TagMeta[] = [];
|
||||
const seen = new Set<string>();
|
||||
Object.keys(groups).forEach((axis, ai) => {
|
||||
let first = true;
|
||||
for (const t of groups[axis] ?? []) {
|
||||
if (seen.has(t) || !tags.includes(t)) continue;
|
||||
seen.add(t);
|
||||
out.push({ tag: t, axisIndex: ai, axisName: axis, groupStart: first });
|
||||
first = false;
|
||||
}
|
||||
});
|
||||
let firstUngrouped = true;
|
||||
for (const t of tags) {
|
||||
if (seen.has(t)) continue;
|
||||
out.push({ tag: t, axisIndex: -1, axisName: "", groupStart: firstUngrouped });
|
||||
firstUngrouped = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collapse an ordered `TagMeta[]` into contiguous axis groups. */
|
||||
export function toGroups(ordered: TagMeta[]): AxisGroup[] {
|
||||
const groups: AxisGroup[] = [];
|
||||
for (const m of ordered) {
|
||||
if (m.groupStart || groups.length === 0) {
|
||||
groups.push({ axisIndex: m.axisIndex, axisName: m.axisName, tags: [], grouped: m.axisIndex >= 0 });
|
||||
}
|
||||
groups[groups.length - 1].tags.push(m.tag);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** A "bucket" is the catch-all uncategorized run: no axis, or a blank axis
|
||||
* name. Buckets are labelled "uncategorized" + tinted neutral rather than
|
||||
* rendered blank. */
|
||||
export function isBucket(grp: AxisGroup): boolean {
|
||||
return !grp.grouped || grp.axisName.trim().length === 0;
|
||||
}
|
||||
|
||||
/** Display label for a group's band / chip. */
|
||||
export function groupLabel(grp: AxisGroup): string {
|
||||
return isBucket(grp) ? "uncategorized" : grp.axisName;
|
||||
}
|
||||
|
||||
/** Neutral grey for the uncategorized bucket — distinct from the per-axis
|
||||
* palette so it doesn't read as a real named axis. */
|
||||
export const UNGROUPED_HUE = "var(--wp-text-muted, #8a8a9a)";
|
||||
|
||||
/** Hue for a group's chips / band / tags. Named axes take the shared palette;
|
||||
* the uncategorized bucket goes neutral grey when it's labelled (mixed
|
||||
* layout), else keeps the legacy source/target tint (fully-flat matrix). */
|
||||
export function groupHue(grp: AxisGroup, role: "source" | "target", labeled: boolean): string {
|
||||
if (!isBucket(grp)) return axisHueAt(grp.axisIndex);
|
||||
return labeled ? UNGROUPED_HUE : `var(--wp-constraint-${role})`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* TS mirror of engine/seed_derive.py — lets the seed modal preview the
|
||||
* per-iteration seed list without running the graph. Parity is guarded
|
||||
* by tests/fixtures/seed-derive-corpus.json. Values masked to 50 bits.
|
||||
*/
|
||||
export type SeedStrategy = "sequential" | "hash_index" | "prime_stride";
|
||||
|
||||
const MASK = (1n << 50n) - 1n;
|
||||
|
||||
export function deriveLoopSeeds(base: number, count: number, strategy: SeedStrategy): number[] {
|
||||
if (count <= 1) return [Number(BigInt(base) & MASK)];
|
||||
if (strategy === "sequential") {
|
||||
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i)) & MASK));
|
||||
}
|
||||
if (strategy === "prime_stride") {
|
||||
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i) * 1000003n) & MASK));
|
||||
}
|
||||
if (strategy === "hash_index") {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const digest = sha256Bytes(`${base}:${i}`);
|
||||
let v = 0n;
|
||||
for (let b = 0; b < 8; b++) v = (v << 8n) | BigInt(digest[b]);
|
||||
return Number(v & MASK);
|
||||
});
|
||||
}
|
||||
throw new Error(`unknown loop seed strategy: ${strategy}`);
|
||||
}
|
||||
|
||||
export function applySeedLocks(derived: number[], locks: Record<number, number>): number[] {
|
||||
return derived.map((s, i) =>
|
||||
Object.prototype.hasOwnProperty.call(locks, i) ? Number(BigInt(locks[i]) & MASK) : s,
|
||||
);
|
||||
}
|
||||
|
||||
const LOCK_LINE = /^\s*#(\d+)\s*:\s*(-?\d+)\s*$/;
|
||||
|
||||
/**
|
||||
* Parse the `#N: seed` lines produced by the seed modal's Copy (1-based
|
||||
* display index) back into a 0-based seed_locks map. Non-matching lines are
|
||||
* ignored; values are masked to 50 bits like the engine (negatives normalize
|
||||
* deterministically). UI-only — no Python mirror, so no parity corpus.
|
||||
*/
|
||||
export function parseSeedLocks(text: string): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(LOCK_LINE);
|
||||
if (!m) continue;
|
||||
const display = Number(m[1]);
|
||||
if (display < 1) continue; // #N is 1-based; #0 / #-1 are invalid
|
||||
out[String(display - 1)] = Number(BigInt(m[2]) & MASK);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sha256Bytes(msg: string): Uint8Array {
|
||||
const K = [
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
||||
let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a,h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
|
||||
const bytes = new TextEncoder().encode(msg);
|
||||
const bitLen = bytes.length * 8;
|
||||
const padded = new Uint8Array((Math.ceil((bytes.length + 1 + 8) / 64)) * 64);
|
||||
padded.set(bytes); padded[bytes.length] = 0x80;
|
||||
const dv = new DataView(padded.buffer);
|
||||
dv.setUint32(padded.length - 4, bitLen >>> 0);
|
||||
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
|
||||
const w = new Uint32Array(64);
|
||||
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3);
|
||||
const s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10);
|
||||
w[i] = (w[i-16] + s0 + w[i-7] + s1) | 0;
|
||||
}
|
||||
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,hh=h7;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
||||
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) | 0;
|
||||
hh=g; g=f; f=e; e=(d + t1)|0; d=c; c=b; b=a; a=(t1 + t2)|0;
|
||||
}
|
||||
h0=(h0+a)|0; h1=(h1+b)|0; h2=(h2+c)|0; h3=(h3+d)|0; h4=(h4+e)|0; h5=(h5+f)|0; h6=(h6+g)|0; h7=(h7+hh)|0;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
const odv = new DataView(out.buffer);
|
||||
[h0,h1,h2,h3,h4,h5,h6,h7].forEach((hv, i) => odv.setUint32(i*4, hv >>> 0));
|
||||
return out;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ function makeRouter() {
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/bundles", name: "bundles", component: { template: "<div/>" } },
|
||||
{ path: "/bundles/:id/edit", name: "bundles-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -582,4 +583,22 @@ describe("BundleEditor.vue", () => {
|
||||
expect(payload.children![0].id).toBe("wc_a");
|
||||
expect(payload.children![1].id).toBe("wc_a");
|
||||
});
|
||||
|
||||
it("create-mode saves a new bundle directly (no Context wrap needed)", async () => {
|
||||
apiBundles.create.mockResolvedValue({
|
||||
id: "bn_new", name: "My Kit", description: "", color: null,
|
||||
category_id: null, tags: [], is_favorite: false, children: [],
|
||||
payload_hash: "h", version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mountEditor(); // no id → create mode
|
||||
await flushPromises();
|
||||
await wrap.find('[data-test="identity-name"]').setValue("My Kit");
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.findAll("button").find((b) => b.text().includes("Save"));
|
||||
await saveBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiBundles.create).toHaveBeenCalledTimes(1);
|
||||
expect(apiBundles.create.mock.calls[0][0].name).toBe("My Kit");
|
||||
expect(apiBundles.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,4 +204,89 @@ describe("ConstraintMatrix.vue", () => {
|
||||
expect(ths.every((th) => th.text() !== "" && th.text().toLowerCase() !== "null")).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("orders cols into axis bands and rows into grouped sections, colored by axis", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["tempting", "man", "woman", "clothed"], // deliberately unordered
|
||||
cols: ["vivid", "warm", "cool"],
|
||||
modelValue: {},
|
||||
sourceGroups: { gender: ["man", "woman"], rating: ["clothed", "tempting"] },
|
||||
targetGroups: { temperature: ["warm", "cool"], saturation: ["vivid"] },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
// Column TAG cells re-ordered by axis order: temperature(warm, cool) then
|
||||
// saturation(vivid). The axis NAME no longer lives in the tag cell.
|
||||
const cols = wrap.findAll(".wp-mx-th-col");
|
||||
expect(cols.map((c) => c.text())).toEqual(["warm", "cool", "vivid"]);
|
||||
// Axis names live in a band row, each spanning its group's tags.
|
||||
const bands = wrap.findAll(".wp-mx-th-band");
|
||||
expect(bands.map((b) => b.text()).filter((t) => t.length > 0))
|
||||
.toEqual(["temperature", "saturation"]);
|
||||
const temperature = bands.find((b) => b.text() === "temperature");
|
||||
expect(temperature?.attributes("colspan")).toBe("2"); // spans warm + cool
|
||||
expect(temperature?.attributes("style") ?? "").toContain("--ax"); // carries the hue
|
||||
// Rows re-ordered: gender(man, woman) then rating(clothed, tempting).
|
||||
const rows = wrap.findAll(".wp-mx-th-row");
|
||||
expect(rows.map((r) => r.text())).toEqual(["man", "woman", "clothed", "tempting"]);
|
||||
// Each multi-tag row group gets a pinned header chip naming the axis.
|
||||
const heads = wrap.findAll(".wp-mx-grp-head");
|
||||
expect(heads.map((h) => h.text())).toEqual(["gender", "rating"]);
|
||||
expect(heads[0].attributes("style") ?? "").toContain("--ax");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a solo grouped row axis folds its name into the tag (no header chip row)", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["lean", "man", "woman"],
|
||||
cols: ["warm"],
|
||||
modelValue: {},
|
||||
sourceGroups: { gender: ["man", "woman"], build: ["lean"] },
|
||||
targetGroups: {},
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
// gender (2 tags) gets a header chip; build (1 tag) does NOT.
|
||||
const heads = wrap.findAll(".wp-mx-grp-head");
|
||||
expect(heads.map((h) => h.text())).toEqual(["gender"]);
|
||||
// The solo "build" axis row carries the axis name as an eyebrow over "lean".
|
||||
const solo = wrap.find(".wp-mx-th-row.solo");
|
||||
expect(solo.exists()).toBe(true);
|
||||
expect(solo.text()).toContain("build");
|
||||
expect(solo.text()).toContain("lean");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["romantic", "elegant", "bold"], // mood axis + ungrouped "bold"
|
||||
cols: ["vivid", "muted", "yellow"], // saturation axis + ungrouped "yellow"
|
||||
modelValue: {},
|
||||
sourceGroups: { mood: ["romantic", "elegant"] },
|
||||
targetGroups: { saturation: ["vivid", "muted"] },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
// Row groups: the real axis then a labelled "uncategorized" bucket (not blank).
|
||||
const heads = wrap.findAll(".wp-mx-grp-head").map((h) => h.text());
|
||||
expect(heads).toEqual(["mood", "uncategorized"]);
|
||||
// Column bands: the real axis then a labelled "uncategorized" band.
|
||||
const bands = wrap.findAll(".wp-mx-th-band").map((b) => b.text()).filter((t) => t.length > 0);
|
||||
expect(bands).toEqual(["saturation", "uncategorized"]);
|
||||
// The bucket carries a non-axis (neutral) hue, not a palette colour.
|
||||
const bucketBand = wrap.findAll(".wp-mx-th-band").find((b) => b.text() === "uncategorized");
|
||||
expect(bucketBand?.classes()).toContain("wp-mx-th-band--bucket");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("stays flat with no 'uncategorized' label when nothing is grouped", () => {
|
||||
const wrap = mountGrid({}, ["red", "blue"], ["warm", "cool"]);
|
||||
expect(wrap.findAll(".wp-mx-grp-head").length).toBe(0);
|
||||
expect(wrap.findAll(".wp-mx-th-band").length).toBe(0);
|
||||
expect(wrap.text().toLowerCase()).not.toContain("uncategorized");
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,4 +66,46 @@ describe("Select.vue", () => {
|
||||
expect(wrap.get("[data-test='select-trigger']").attributes("aria-invalid")).toBe("true");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("type-to-filter narrows the options", async () => {
|
||||
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(document.querySelectorAll(".wp-select__option").length).toBe(3);
|
||||
await trigger.trigger("keydown", { key: "a" });
|
||||
await trigger.trigger("keydown", { key: "l" }); // "al" → only "Alpha"
|
||||
expect(document.querySelectorAll(".wp-select__option").length).toBe(1);
|
||||
expect(document.querySelector(".wp-select__option")?.textContent).toContain("Alpha");
|
||||
expect(document.querySelector("[data-test='select-filter']")?.textContent).toContain("al");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("Enter selects the single filtered match", async () => {
|
||||
const wrap = mount(Select, {
|
||||
props: { modelValue: null, options: opts },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const trigger = wrap.get("[data-test='select-trigger']");
|
||||
await trigger.trigger("click");
|
||||
await trigger.trigger("keydown", { key: "g" }); // → only "Gamma"
|
||||
await trigger.trigger("keydown", { key: "Enter" });
|
||||
expect(wrap.emitted("update:modelValue")![0]).toEqual(["c"]);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("shows No matches when the query matches nothing", async () => {
|
||||
const wrap = mount(Select, {
|
||||
props: { modelValue: null, options: opts },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const trigger = wrap.get("[data-test='select-trigger']");
|
||||
await trigger.trigger("click");
|
||||
await trigger.trigger("keydown", { key: "z" });
|
||||
expect(document.querySelectorAll(".wp-select__option").length).toBe(0);
|
||||
expect(document.querySelector("[data-test='select-empty']")).not.toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,18 +44,30 @@ describe("BundleReattachSection", () => {
|
||||
const w = mountSection();
|
||||
// Leaf (wildcard) child → module candidates.
|
||||
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
|
||||
const leafCands = w.findAll("[data-test='bundle-reattach-candidate']").map((c) => c.text());
|
||||
const leafCands = w.findAll("[data-test='bundle-reattach-cand-name']").map((c) => c.text());
|
||||
expect(leafCands).toContain("Live module");
|
||||
expect(leafCands).toContain("Other module");
|
||||
expect(leafCands).not.toContain("Live bundle");
|
||||
|
||||
// Bundle child → bundle candidates.
|
||||
await w.find("[data-test='bundle-reattach-btn-bbb']").trigger("click");
|
||||
const bundleCands = w.findAll("[data-test='bundle-reattach-candidate']").map((c) => c.text());
|
||||
const bundleCands = w.findAll("[data-test='bundle-reattach-cand-name']").map((c) => c.text());
|
||||
expect(bundleCands).toContain("Live bundle");
|
||||
expect(bundleCands).not.toContain("Live module");
|
||||
});
|
||||
|
||||
it("shows each candidate's id so same-named modules are distinguishable", async () => {
|
||||
const w = mountSection({
|
||||
moduleCandidates: [
|
||||
{ id: "mod1", name: "color" },
|
||||
{ id: "mod2", name: "color" },
|
||||
],
|
||||
});
|
||||
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
|
||||
expect(w.find("[data-test-id='bundle-reattach-candidate-mod1']").text()).toContain("mod1");
|
||||
expect(w.find("[data-test-id='bundle-reattach-candidate-mod2']").text()).toContain("mod2");
|
||||
});
|
||||
|
||||
it("picking + confirming emits reattach with childId + newId + newName", async () => {
|
||||
const w = mountSection();
|
||||
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
|
||||
|
||||
@@ -143,7 +143,10 @@ function confirm(childId: string): void {
|
||||
:data-test-id="`bundle-reattach-candidate-${c.id}`"
|
||||
:class="{ 'rb__cand--picked': c.id === picked?.id }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-name" data-test="bundle-reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-id">{{ c.id }}</code>
|
||||
</li>
|
||||
<li v-if="candidatesFor(child).length === 0" class="rb__empty">
|
||||
No local {{ child.type === "bundle" ? "bundles" : "modules" }} to reattach.
|
||||
</li>
|
||||
@@ -226,7 +229,9 @@ function confirm(childId: string): void {
|
||||
outline: none;
|
||||
}
|
||||
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
||||
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand-name { font: 11px var(--wp-font-sans); }
|
||||
.rb__cand-id { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
|
||||
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.rb__empty { padding: 5px 8px; font: 11px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* Single popover lives in a body portal so it never picks up the cell's
|
||||
* hover / clipping context — same fix the canvas grid applies.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type {
|
||||
ConstraintCell,
|
||||
ConstraintMatrix,
|
||||
@@ -23,6 +23,13 @@ import type {
|
||||
} from "../api/types";
|
||||
import CellRulePopover from "../../components/context/editors/constraint/CellRulePopover.vue";
|
||||
import MatrixLegend from "../../components/context/editors/constraint/MatrixLegend.vue";
|
||||
import {
|
||||
groupHue as tagHue,
|
||||
groupLabel,
|
||||
isBucket,
|
||||
orderByGroups,
|
||||
toGroups,
|
||||
} from "../../components/shared/matrix-axis";
|
||||
|
||||
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -32,6 +39,13 @@ interface Props {
|
||||
modelValue: ConstraintMatrix;
|
||||
sourceName?: string; // wildcard name shown in the source axis tag
|
||||
targetName?: string; // wildcard name shown in the target axis tag
|
||||
/** Sub-category axes (tag_groups) of the source/target wildcards:
|
||||
* axis name → member tags. When present, the matrix orders its rows /
|
||||
* cols by axis and colors each group via `axisHueAt` (mirrors the
|
||||
* wildcard editor's per-axis chip hues). Empty → flat, source/target-
|
||||
* tinted headers as before. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
// Read-only recovery view: the source/target wildcard was deleted, so the
|
||||
// configured rules are shown for understanding only. Cells don't open the
|
||||
// rule popover; reattach a live wildcard to edit.
|
||||
@@ -40,6 +54,8 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sourceName: "",
|
||||
targetName: "",
|
||||
sourceGroups: () => ({}),
|
||||
targetGroups: () => ({}),
|
||||
readonly: false,
|
||||
});
|
||||
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
|
||||
@@ -116,6 +132,30 @@ function fmtFactor(f: number): string {
|
||||
return f.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
// ── Axis grouping (target = columns, source = rows). Ordering, grouping,
|
||||
// bucket detection + hues come from the shared matrix-axis module so this grid
|
||||
// and the canvas MatrixSection stay identical. ──────────────────
|
||||
const orderedRows = computed(() => orderByGroups(props.rows, props.sourceGroups));
|
||||
const orderedCols = computed(() => orderByGroups(props.cols, props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedRows.value));
|
||||
const colGroups = computed(() => toGroups(orderedCols.value));
|
||||
/** True when at least one column / row axis is a NAMED group — then the header
|
||||
* grows a band/chip and the leftover bucket is worth labelling. A fully-flat
|
||||
* matrix keeps the legacy source/target tint and grows no band/chip. */
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
// The column band row's height is measured at runtime so the tag row beneath
|
||||
// it can stick at the right `top` offset, independent of font sizing.
|
||||
const gridEl = ref<HTMLElement | null>(null);
|
||||
function measureBands(): void {
|
||||
const grid = gridEl.value;
|
||||
if (!grid) return;
|
||||
const band = grid.querySelector<HTMLElement>(".wp-mx-th-band");
|
||||
grid.style.setProperty("--wp-mx-band-h", `${band?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
watch(colGroups, () => void nextTick(measureBands));
|
||||
|
||||
function cellAriaLabel(row: string, col: string): string {
|
||||
const c = cellAt(row, col);
|
||||
const factor = (c.mode === "boost" || c.mode === "reduce") ? ` ×${fmtFactor(c.factor)}` : "";
|
||||
@@ -228,12 +268,15 @@ onMounted(() => {
|
||||
window.addEventListener("keydown", onDocumentKeydown);
|
||||
window.addEventListener("scroll", reanchorPopover, true);
|
||||
window.addEventListener("resize", reanchorPopover);
|
||||
window.addEventListener("resize", measureBands);
|
||||
void nextTick(measureBands);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("mousedown", onDocumentMousedown);
|
||||
window.removeEventListener("keydown", onDocumentKeydown);
|
||||
window.removeEventListener("scroll", reanchorPopover, true);
|
||||
window.removeEventListener("resize", reanchorPopover);
|
||||
window.removeEventListener("resize", measureBands);
|
||||
});
|
||||
|
||||
defineExpose({ cellAt });
|
||||
@@ -256,50 +299,111 @@ defineExpose({ cellAt });
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wp-mx-grid">
|
||||
<div ref="gridEl" class="wp-mx-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wp-mx-corner" data-test="mx-corner">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
<th class="wp-mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
|
||||
<span class="chip">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
v-for="col in cols"
|
||||
:key="col"
|
||||
class="wp-mx-th-col"
|
||||
>{{ col }}</th>
|
||||
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
|
||||
<template v-if="hasColBands">
|
||||
<th
|
||||
v-for="(grp, gi) in colGroups"
|
||||
:key="`band-${gi}`"
|
||||
class="wp-mx-th-band"
|
||||
:class="{ 'wp-mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
>
|
||||
<span class="chip">{{ groupLabel(grp) }}</span>
|
||||
</th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
<th
|
||||
v-for="c in orderedCols"
|
||||
:key="c.tag"
|
||||
class="wp-mx-th-col"
|
||||
:style="{ '--ax': 'var(--wp-constraint-target)' }"
|
||||
><span class="chip">{{ c.tag }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="hasColBands">
|
||||
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
|
||||
<th
|
||||
v-for="(tag, ti) in grp.tags"
|
||||
:key="tag"
|
||||
class="wp-mx-th-col"
|
||||
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
|
||||
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
|
||||
><span class="chip">{{ tag }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row">
|
||||
<th class="wp-mx-th-row">{{ row }}</th>
|
||||
<td v-for="col in cols" :key="col" class="wp-mx-td">
|
||||
<div
|
||||
class="wp-mx-cell"
|
||||
:class="[
|
||||
`s-${toState(cellAt(row, col).mode)}`,
|
||||
{ open: isOpenAt(row, col) },
|
||||
]"
|
||||
:data-mode="cellAt(row, col).mode"
|
||||
:data-test="`cell-${row}-${col}`"
|
||||
:aria-label="cellAriaLabel(row, col)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openPopover(row, col, $event)"
|
||||
@keydown.enter.prevent="openPopover(row, col, $event as unknown as MouseEvent)"
|
||||
@keydown.space.prevent="openPopover(row, col, $event as unknown as MouseEvent)"
|
||||
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
|
||||
<!-- Header chip: a named multi-tag axis, OR the labelled
|
||||
uncategorized bucket (when the matrix has any named axis). A
|
||||
named solo axis folds into an eyebrow instead (below). -->
|
||||
<tr
|
||||
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
|
||||
class="wp-mx-grp-row"
|
||||
>
|
||||
<th
|
||||
class="wp-mx-grp-head"
|
||||
:class="{ 'wp-mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedCols.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</tr>
|
||||
<tr v-for="tag in grp.tags" :key="tag">
|
||||
<th
|
||||
class="wp-mx-th-row"
|
||||
:class="{
|
||||
'group-start': !isBucket(grp) && tag === grp.tags[0],
|
||||
solo: !isBucket(grp) && grp.tags.length === 1,
|
||||
}"
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span class="glyph">{{ MODE_ICON[cellAt(row, col).mode] }}</span>
|
||||
<span
|
||||
v-if="cellAt(row, col).mode === 'boost' || cellAt(row, col).mode === 'reduce'"
|
||||
class="factor"
|
||||
>×{{ fmtFactor(cellAt(row, col).factor) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="cols.length + 1" class="wp-mx-empty">
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="v">{{ tag }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ tag }}</span>
|
||||
</th>
|
||||
<td v-for="c in orderedCols" :key="c.tag" class="wp-mx-td">
|
||||
<div
|
||||
class="wp-mx-cell"
|
||||
:class="[
|
||||
`s-${toState(cellAt(tag, c.tag).mode)}`,
|
||||
{ open: isOpenAt(tag, c.tag) },
|
||||
]"
|
||||
:data-mode="cellAt(tag, c.tag).mode"
|
||||
:data-test="`cell-${tag}-${c.tag}`"
|
||||
:aria-label="cellAriaLabel(tag, c.tag)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openPopover(tag, c.tag, $event)"
|
||||
@keydown.enter.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
|
||||
@keydown.space.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
|
||||
>
|
||||
<span class="glyph">{{ MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
|
||||
<span
|
||||
v-if="cellAt(tag, c.tag).mode === 'boost' || cellAt(tag, c.tag).mode === 'reduce'"
|
||||
class="factor"
|
||||
>×{{ fmtFactor(cellAt(tag, c.tag).factor) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="orderedRows.length === 0">
|
||||
<td :colspan="orderedCols.length + 1" class="wp-mx-empty">
|
||||
No source values yet — add options to the source wildcard first.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -386,52 +490,174 @@ defineExpose({ cellAt });
|
||||
}
|
||||
.wp-mx-axis .arrow { font: 14px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
/* ── Grid frame ────────────────────────────────────────────── */
|
||||
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
|
||||
.wp-mx-grid {
|
||||
display: inline-block;
|
||||
background: var(--wp-bg-deep, var(--wp-bg-1, #0e1015));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
overflow-x: auto;
|
||||
/* No padding on the SCROLL container: position:sticky pins headers at the
|
||||
* padding edge while overflow still renders scrolling cells INSIDE the
|
||||
* padding, so any padding leaves a bleed strip of scrolled cells on the
|
||||
* top/left scroll-start sides. Content sits flush; the border + radius give
|
||||
* the frame. */
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.wp-mx-grid table { border-collapse: separate; border-spacing: 4px; }
|
||||
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
|
||||
* border-collapse have a Chromium paint bug where scrolled cells bleed through
|
||||
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
|
||||
* so the layout is unchanged. */
|
||||
.wp-mx-grid table { border-collapse: separate; border-spacing: 0; }
|
||||
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
|
||||
* OPAQUE grid bg so scrolled cells can't bleed through them; that fill also
|
||||
* paints the padding strip, which is what reads as the gap. */
|
||||
.wp-mx-grid th,
|
||||
.wp-mx-grid td { padding: 0; vertical-align: middle; }
|
||||
.wp-mx-grid td { padding: 2px; vertical-align: middle; }
|
||||
.wp-mx-grid thead th,
|
||||
.wp-mx-grid tbody th {
|
||||
position: sticky;
|
||||
background: var(--wp-bg-deep, var(--wp-bg-1, #0e1015));
|
||||
}
|
||||
.wp-mx-grid thead th { top: 0; z-index: 2; }
|
||||
.wp-mx-grid tbody th { left: 0; z-index: 1; }
|
||||
/* Band row pins to the very top; the tag row sticks just beneath it (offset =
|
||||
* the measured band height). Corner outranks both so it stays in the angle. */
|
||||
.wp-mx-grid thead th.wp-mx-th-band { top: 0; z-index: 3; }
|
||||
.wp-mx-grid thead th.wp-mx-th-col { top: var(--wp-mx-band-h, 0px); z-index: 2; }
|
||||
.wp-mx-grid thead th.wp-mx-corner { top: 0; left: 0; z-index: 5; } /* both axes */
|
||||
|
||||
.wp-mx-corner {
|
||||
/* Inner chips carry the rounded visual + per-axis tint (--ax, set inline);
|
||||
* the <th> itself stays an opaque grid-bg backing for the sticky fill. */
|
||||
.wp-mx-corner .chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
min-height: 36px;
|
||||
height: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--wp-text-dim, var(--wp-text-muted));
|
||||
border-radius: 4px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
padding: 0 8px !important;
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, var(--wp-bg-1));
|
||||
}
|
||||
.wp-mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
|
||||
.wp-mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
|
||||
.wp-mx-corner .row-1 { color: var(--wp-constraint-source-text); }
|
||||
.wp-mx-corner .row-2 { color: var(--wp-constraint-target-text); }
|
||||
|
||||
.wp-mx-th-col {
|
||||
width: 64px;
|
||||
height: 28px;
|
||||
font: 700 10px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-constraint-target-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
/* Column band — axis name spanning its tags, sitting atop the tag row. */
|
||||
.wp-mx-th-band .chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font: 700 9px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-1));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
.wp-mx-th-row {
|
||||
/* The uncategorized bucket reads as a catch-all, not a real axis: same neutral
|
||||
* tint (via --ax) but a dashed edge instead of the solid axis accent. */
|
||||
.wp-mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
.wp-mx-grp-head--bucket .chip { border-left-style: dashed; }
|
||||
|
||||
/* Column tag chip — the per-tag header below the band (or the only header row
|
||||
* when columns are ungrouped). */
|
||||
.wp-mx-th-col .chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
font: 700 10px var(--wp-font-sans, sans-serif);
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-1));
|
||||
}
|
||||
.wp-mx-th-col.group-start .chip {
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Row group header chip — names the axis above its tags, pinned to the left
|
||||
* edge so it stays visible while cells scroll horizontally. The <th> spans the
|
||||
* row but stays transparent; only the chip is visible + sticky. */
|
||||
.wp-mx-grp-row .wp-mx-grp-head {
|
||||
position: relative; /* override the sticky tbody th; the chip pins */
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
padding: 4px 0 1px;
|
||||
text-align: left;
|
||||
}
|
||||
.wp-mx-grp-head .chip {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
height: 22px;
|
||||
padding: 0 11px;
|
||||
font: 700 9px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-1));
|
||||
border-left: 3px solid var(--ax);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
|
||||
.wp-mx-th-row .chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font: 700 10px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-constraint-source-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
padding: 0 12px !important;
|
||||
text-align: left;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-1));
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
|
||||
}
|
||||
/* Solo grouped axis — no header chip row; fold the axis name in as an eyebrow. */
|
||||
.wp-mx-th-row.solo .chip {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border-left-width: 3px;
|
||||
border-left-color: var(--ax);
|
||||
}
|
||||
.wp-mx-th-row.solo .eye {
|
||||
max-width: 78px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 700 7px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
|
||||
}
|
||||
.wp-mx-th-row.solo .v {
|
||||
font: 600 11px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-mx-td { position: relative; }
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@ describe("RemapRefPopup", () => {
|
||||
expect(rows[0].text()).toContain("texture");
|
||||
});
|
||||
|
||||
it("shows each candidate's uuid so same-named wildcards are distinguishable", () => {
|
||||
const rd = refData();
|
||||
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
|
||||
const w = mountPopup({ cachedName: "", refData: rd });
|
||||
expect(w.find("[data-test-id='remap-candidate-beef0001']").text()).toContain("beef0001");
|
||||
expect(w.find("[data-test-id='remap-candidate-beef0002']").text()).toContain("beef0002");
|
||||
});
|
||||
|
||||
it("after picking a wildcard, shows the dropped-token preview struck through", async () => {
|
||||
const w = mountPopup();
|
||||
await w.find("[data-test-id='remap-candidate-beef0001']").trigger("click");
|
||||
|
||||
@@ -141,7 +141,7 @@ function doConfirm(): void {
|
||||
@click="pick(c.uuid)"
|
||||
>
|
||||
<span class="wpc-remap__cand-name">{{ c.name }}</span>
|
||||
<span class="wpc-remap__cand-meta">{{ c.count }} opts</span>
|
||||
<span class="wpc-remap__cand-meta"><code class="wpc-remap__cand-uuid" data-test="remap-cand-uuid">{{ c.uuid }}</code> · {{ c.count }} opts</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -249,6 +249,7 @@ function doConfirm(): void {
|
||||
.wpc-remap__candidate:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.wpc-remap__candidate--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.wpc-remap__cand-meta { font: 10px var(--wp-font-mono); color: var(--wp-text-dim, #8a8a93); }
|
||||
.wpc-remap__cand-uuid { font: inherit; color: var(--wp-text-muted, var(--wp-text-dim, #8a8a93)); }
|
||||
.wpc-remap__reconcile {
|
||||
border-top: 1px dashed var(--wp-border, rgba(255,255,255,0.12));
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -35,12 +35,23 @@ const btnRef = ref<HTMLButtonElement | null>(null);
|
||||
const open = ref(false);
|
||||
const flip = ref(false);
|
||||
const active = ref(0);
|
||||
/** Type-to-filter query, built from printable keystrokes while the menu
|
||||
* is open. Reset every time the menu opens or closes. */
|
||||
const query = ref("");
|
||||
|
||||
/** 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);
|
||||
|
||||
/** Options narrowed by `query` (case-insensitive substring on the label).
|
||||
* Empty query → all options, so every dropdown is type-to-filter. */
|
||||
const filtered = computed<SelectOption[]>(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return props.options;
|
||||
return props.options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const btnClasses = computed(() => [
|
||||
"wp-select",
|
||||
props.size === "sm" && "wp-select--sm",
|
||||
@@ -137,11 +148,13 @@ function computeMenuStyle() {
|
||||
|
||||
watch(open, (v) => {
|
||||
if (!v) {
|
||||
query.value = "";
|
||||
window.removeEventListener("scroll", closeOnScroll, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
return;
|
||||
}
|
||||
if (!btnRef.value) return;
|
||||
query.value = "";
|
||||
computeMenuStyle();
|
||||
// Highlight currently-selected option (or first).
|
||||
const idx = props.options.findIndex((o) => o.value === props.modelValue);
|
||||
@@ -177,19 +190,29 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = filtered.value;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
open.value = false;
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
active.value = (active.value + 1) % props.options.length;
|
||||
if (list.length) active.value = (active.value + 1) % list.length;
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
active.value = (active.value - 1 + props.options.length) % props.options.length;
|
||||
if (list.length) active.value = (active.value - 1 + list.length) % list.length;
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const opt = props.options[active.value];
|
||||
const opt = list[active.value];
|
||||
if (opt) pick(opt);
|
||||
} else if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
query.value = query.value.slice(0, -1);
|
||||
active.value = 0;
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Printable key → type-to-filter; jump the highlight back to the top.
|
||||
e.preventDefault();
|
||||
query.value += e.key;
|
||||
active.value = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -242,7 +265,14 @@ function onKeydown(e: KeyboardEvent) {
|
||||
data-test="select-menu"
|
||||
>
|
||||
<li
|
||||
v-for="(opt, i) in options"
|
||||
v-if="query"
|
||||
class="wp-select__filter"
|
||||
aria-hidden="true"
|
||||
data-test="select-filter"
|
||||
style="display:flex;align-items:center;padding:4px 12px;font-size:11px;opacity:0.6;"
|
||||
>Filtering: {{ query }}</li>
|
||||
<li
|
||||
v-for="(opt, i) in filtered"
|
||||
:key="String(opt.value)"
|
||||
class="wp-select__option"
|
||||
role="option"
|
||||
@@ -262,6 +292,13 @@ function onKeydown(e: KeyboardEvent) {
|
||||
<span class="wp-spacer" />
|
||||
<Icon v-if="opt.value === modelValue" name="check" :size="ICON_SM" />
|
||||
</li>
|
||||
<li
|
||||
v-if="!filtered.length"
|
||||
class="wp-select__empty"
|
||||
aria-disabled="true"
|
||||
data-test="select-empty"
|
||||
style="padding:6px 12px;opacity:0.55;"
|
||||
>No matches</li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
detectInstallCollisions,
|
||||
enforceClashSafety,
|
||||
installEnvelope,
|
||||
normalizeInvalidIds,
|
||||
type CollisionDecision,
|
||||
type InstallCollision,
|
||||
type InstallOptions,
|
||||
@@ -25,6 +26,37 @@ function emptySelection(): ResolvedSelection {
|
||||
} as unknown as ResolvedSelection;
|
||||
}
|
||||
|
||||
describe("normalizeInvalidIds", () => {
|
||||
it("mints a valid 8-hex id for a non-hex module id, rewrites it in place", () => {
|
||||
const selection = emptySelection();
|
||||
selection.wildcards = [
|
||||
{ entity: { id: "coloruni", name: "color" }, decision: { kind: "add" } },
|
||||
{ entity: { id: "ed1bccf8", name: "decor" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["wildcards"];
|
||||
const map = normalizeInvalidIds(selection);
|
||||
expect(Object.keys(map)).toEqual(["coloruni"]); // valid id untouched
|
||||
expect(map.coloruni).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect((selection.wildcards[0].entity as { id: string }).id).toBe(map.coloruni);
|
||||
expect((selection.wildcards[1].entity as { id: string }).id).toBe("ed1bccf8");
|
||||
});
|
||||
|
||||
it("returns an empty map when every id is already valid", () => {
|
||||
const selection = emptySelection();
|
||||
selection.wildcards = [
|
||||
{ entity: { id: "ed1bccf8", name: "decor" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["wildcards"];
|
||||
expect(normalizeInvalidIds(selection)).toEqual({});
|
||||
});
|
||||
|
||||
it("normalizes bundle ids too", () => {
|
||||
const selection = emptySelection();
|
||||
selection.bundles = [
|
||||
{ entity: { id: "mybundle", name: "b" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["bundles"];
|
||||
expect(normalizeInvalidIds(selection).mybundle).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallCollisions — type-conflict flag", () => {
|
||||
it("flags a same-id DIFFERENT-kind collision as typeConflict", () => {
|
||||
const selection = emptySelection();
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type ResolvedEntity,
|
||||
type ResolvedSelection,
|
||||
} from "./commit";
|
||||
import { newShortId } from "../utils/ids";
|
||||
import { newShortId, isValidId } from "../utils/ids";
|
||||
import type { SchemaCatalogEntry } from "@/api/types";
|
||||
import { buildReattachRemap, type InstallDependencyEdge } from "./reattach";
|
||||
import {
|
||||
@@ -359,6 +359,37 @@ export function applyCollisionDecisions(
|
||||
return renameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh valid id for any module / bundle / template whose id isn't a
|
||||
* canonical 8-hex uuid (e.g. a hand-authored `coloruni` that round-tripped
|
||||
* through import → community → download), rewriting `entity.id` in place.
|
||||
* Returns the old→new map so the caller folds it into the reattach remap —
|
||||
* `walkRemap` then rewrites every `@{oldid}` ref + constraint source/target
|
||||
* axis to the new id. Option ids are left alone (opaque; never `@{}`-matched).
|
||||
*
|
||||
* Runs BEFORE collision detection so the minted ids (fresh, non-colliding)
|
||||
* never trip the collision path and the two rename maps merge cleanly.
|
||||
*/
|
||||
export function normalizeInvalidIds(selection: ResolvedSelection): Record<string, string> {
|
||||
const renameMap: Record<string, string> = {};
|
||||
const buckets: Array<keyof Omit<ResolvedSelection, "categories">> = [
|
||||
"bundles",
|
||||
...MODULE_BUCKETS,
|
||||
"templates",
|
||||
];
|
||||
for (const bucket of buckets) {
|
||||
for (const row of selection[bucket]) {
|
||||
const id = (row.entity as { id?: unknown }).id;
|
||||
if (typeof id === "string" && !isValidId(id)) {
|
||||
const newId = newShortId();
|
||||
renameMap[id] = newId;
|
||||
(row.entity as { id: string }).id = newId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return renameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety backstop for the shared id-space: a cross-kind id clash
|
||||
* (`typeConflict`) must NEVER resolve to `replace` — replacing the live
|
||||
@@ -444,7 +475,9 @@ export async function installEnvelope(
|
||||
// we fall through and let the server's commit endpoint surface the
|
||||
// conflict as a 4xx, preserving the original behaviour for callers
|
||||
// that haven't opted in to the new UX.
|
||||
let renameMap: Record<string, string> = {};
|
||||
// Normalize any non-8-hex module/bundle id (mint + rewrite in place) FIRST,
|
||||
// so the minted ids feed both collision detection and the reattach remap.
|
||||
const renameMap: Record<string, string> = normalizeInvalidIds(selection);
|
||||
if (opts.library && opts.resolveCollisions) {
|
||||
const collisions = detectInstallCollisions(selection, opts.library);
|
||||
if (collisions.length > 0) {
|
||||
@@ -458,7 +491,8 @@ export async function installEnvelope(
|
||||
error: { code: "cancelled", message: "Install cancelled by user." },
|
||||
};
|
||||
}
|
||||
renameMap = applyCollisionDecisions(selection, enforceClashSafety(collisions, decisions));
|
||||
// Merge (not replace) so the invalid-id renames from normalizeInvalidIds survive.
|
||||
Object.assign(renameMap, applyCollisionDecisions(selection, enforceClashSafety(collisions, decisions)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Boot wiring for the one-time library UUID repair (Part 3). Builds the
|
||||
* dependency-injected `RepairDeps` from the live api client + gates the run so
|
||||
* it fires once per repair version. Kept separate from `uuid-repair.ts` (which
|
||||
* stays pure + server-free) so the orchestrator unit-tests don't pull the api.
|
||||
*/
|
||||
import { api } from "../api/client";
|
||||
import type { ModuleType } from "../api/types";
|
||||
import { runLibraryRepair, type RepairableEntry, type RepairDeps } from "./uuid-repair";
|
||||
|
||||
const REPAIR_KEY = "wp_uuid_repair_version";
|
||||
/** Bump to force the repair to re-run after a future fix. */
|
||||
const REPAIR_VERSION = 1;
|
||||
|
||||
function apiRepairDeps(): RepairDeps {
|
||||
return {
|
||||
listModules: async () => (await api.modules.list({})).items as unknown as RepairableEntry[],
|
||||
listBundles: async () => (await api.bundles.list({})).items as unknown as RepairableEntry[],
|
||||
createModule: async (e) => {
|
||||
const row = await api.modules.create({
|
||||
type: e.type as ModuleType,
|
||||
name: String(e.name ?? ""),
|
||||
description: typeof e.description === "string" ? e.description : undefined,
|
||||
category_id: (e.category_id as string | null | undefined) ?? null,
|
||||
tags: Array.isArray(e.tags) ? (e.tags as string[]) : undefined,
|
||||
payload: e.payload ?? {},
|
||||
content_rating: e.content_rating as "safe" | "nsfw" | undefined,
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
createBundle: async (e) => {
|
||||
const row = await api.bundles.create({
|
||||
name: String(e.name ?? ""),
|
||||
description: typeof e.description === "string" ? e.description : undefined,
|
||||
color: (e.color as string | null | undefined) ?? null,
|
||||
category_id: (e.category_id as string | null | undefined) ?? null,
|
||||
tags: Array.isArray(e.tags) ? (e.tags as string[]) : undefined,
|
||||
children: (e.children as Array<Record<string, unknown>> | undefined) ?? [],
|
||||
content_rating: e.content_rating as "safe" | "nsfw" | undefined,
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
updateModule: async (id, payload) => {
|
||||
await api.modules.update(id, { payload });
|
||||
},
|
||||
updateBundle: async (id, children) => {
|
||||
await api.bundles.update(id, { children: children as Array<Record<string, unknown>> });
|
||||
},
|
||||
deleteModule: async (id) => {
|
||||
await api.modules.delete(id);
|
||||
},
|
||||
deleteBundle: async (id) => {
|
||||
await api.bundles.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the library UUID repair once per `REPAIR_VERSION`. Fire-and-forget from
|
||||
* boot — never throws, never blocks first paint. The version marker is set
|
||||
* ONLY after a successful run, so a crash (or an offline DB) simply retries on
|
||||
* the next load. Returns the number of rows rekeyed (0 = nothing to do / gated
|
||||
* / failed), so the caller can refresh its catalogs when something changed.
|
||||
*/
|
||||
export async function repairLibraryOnce(): Promise<number> {
|
||||
try {
|
||||
const done = Number(localStorage.getItem(REPAIR_KEY) ?? "0");
|
||||
if (done >= REPAIR_VERSION) return 0;
|
||||
const { repaired } = await runLibraryRepair(apiRepairDeps());
|
||||
localStorage.setItem(REPAIR_KEY, String(REPAIR_VERSION));
|
||||
if (repaired > 0) {
|
||||
console.info(`[wp] library uuid repair: rekeyed ${repaired} row(s) with invalid ids`);
|
||||
}
|
||||
return repaired;
|
||||
} catch (err) {
|
||||
console.warn("[wp] library uuid repair failed (will retry next load):", err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findInvalidIds,
|
||||
planRemapUpdates,
|
||||
runLibraryRepair,
|
||||
type RepairableEntry,
|
||||
type RepairDeps,
|
||||
} from "./uuid-repair";
|
||||
|
||||
describe("findInvalidIds", () => {
|
||||
it("returns the ids that fail the 8-hex shape", () => {
|
||||
expect(
|
||||
findInvalidIds([{ id: "coloruni" }, { id: "ed1bccf8" }, { id: "mybundle" }]),
|
||||
).toEqual(["coloruni", "mybundle"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRemapUpdates", () => {
|
||||
it("rewrites nested @{} (suffix kept) + whole-string axes; skips unchanged rows", () => {
|
||||
const updates = planRemapUpdates(
|
||||
[
|
||||
{ id: "aabbccdd", payload: { options: [{ id: "o1", value: "a @{coloruni#color:light or muted}" }] } },
|
||||
{ id: "ccddee11", payload: { source_wildcard_id: "coloruni", matrix: {} } },
|
||||
{ id: "ffff0000", payload: { options: [{ id: "o2", value: "plain text" }] } },
|
||||
],
|
||||
{ coloruni: "c0000001" },
|
||||
);
|
||||
expect((updates.find((u) => u.id === "aabbccdd")?.payload?.options as Array<{ value: string }>)[0].value).toBe(
|
||||
"a @{c0000001#color:light or muted}",
|
||||
);
|
||||
expect(updates.find((u) => u.id === "ccddee11")?.payload?.source_wildcard_id).toBe("c0000001");
|
||||
expect(updates.find((u) => u.id === "ffff0000")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an empty rename map yields no updates", () => {
|
||||
expect(planRemapUpdates([{ id: "x", payload: {} }], {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLibraryRepair", () => {
|
||||
function mockDeps(modules: RepairableEntry[], bundles: RepairableEntry[] = []) {
|
||||
const calls: string[] = [];
|
||||
let n = 0;
|
||||
const deps: RepairDeps = {
|
||||
listModules: async () => modules,
|
||||
listBundles: async () => bundles,
|
||||
createModule: async (e) => { calls.push(`create:${e.id}`); return { id: `aaaa000${++n}` }; },
|
||||
createBundle: async (e) => { calls.push(`createB:${e.id}`); return { id: `bbbb000${++n}` }; },
|
||||
updateModule: async (id) => { calls.push(`update:${id}`); },
|
||||
updateBundle: async (id) => { calls.push(`updateB:${id}`); },
|
||||
deleteModule: async (id) => { calls.push(`delete:${id}`); },
|
||||
deleteBundle: async (id) => { calls.push(`deleteB:${id}`); },
|
||||
};
|
||||
return { deps, calls: () => calls };
|
||||
}
|
||||
|
||||
it("rekeys an invalid module + remaps its referrer in create→update→delete order", async () => {
|
||||
const { deps, calls } = mockDeps([
|
||||
{ id: "coloruni", type: "wildcard", payload: { options: [] } },
|
||||
{ id: "aabbccdd", type: "wildcard", payload: { options: [{ id: "o1", value: "@{coloruni}" }] } },
|
||||
]);
|
||||
const res = await runLibraryRepair(deps);
|
||||
expect(res.repaired).toBe(1);
|
||||
// create the new color row → update the referrer (now @{aaaa0001}) → delete old color.
|
||||
// The created color's own payload has no refs, so it isn't re-updated.
|
||||
expect(calls()).toEqual(["create:coloruni", "update:aabbccdd", "delete:coloruni"]);
|
||||
});
|
||||
|
||||
it("is a no-op on a clean library", async () => {
|
||||
const { deps, calls } = mockDeps([{ id: "ed1bccf8", type: "wildcard", payload: { options: [] } }]);
|
||||
const res = await runLibraryRepair(deps);
|
||||
expect(res.repaired).toBe(0);
|
||||
expect(calls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Library UUID repair (Part 3 of the uuid-fixer).
|
||||
*
|
||||
* Modules/bundles authored with a non-8-hex id (e.g. `coloruni`) can't be
|
||||
* nested-referenced (`@{coloruni}` matches neither the editor chip tokenizer
|
||||
* nor the engine resolver — both `[0-9a-f]{8}`). PR #5 fixes ids on the way IN
|
||||
* (import/download); this repairs ids ALREADY in the local library.
|
||||
*
|
||||
* `create` server-mints the id (no `id` field on the create body), so the new
|
||||
* id is only known AFTER the row is created. The orchestrator therefore:
|
||||
* create copies (server mints) → build old→new map → remap referrers + the
|
||||
* created rows → update → delete the old rows.
|
||||
*
|
||||
* Pure helpers (`findInvalidIds`, `planRemapUpdates`, `remapAll`) are exported
|
||||
* for unit tests; `runLibraryRepair` is dependency-injected so it tests against
|
||||
* a mock store with no server. Scope: TOP-LEVEL module + bundle ids (bundle
|
||||
* child-snapshot ids are a documented follow-up).
|
||||
*/
|
||||
import { isValidId } from "../utils/ids";
|
||||
import { walkRemap } from "../../components/context/bundles/uuid-remap";
|
||||
|
||||
/** Minimal shape the repair needs from a library row (module or bundle). */
|
||||
export interface RepairableEntry {
|
||||
id: string;
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite nested `@{oldid…}` refs whose id is one of the renamed ids.
|
||||
* `walkRemap`'s `REF_RE` only matches `[0-9a-f]{6,16}`, so it SILENTLY skips a
|
||||
* non-hex id like `@{coloruni}` — exactly the ids we repair. Matches up to a
|
||||
* ref delimiter (`}` `#` `:` `!`) so the `#name`/`:filter`/`!null` suffix is
|
||||
* preserved verbatim.
|
||||
*/
|
||||
function rewriteInvalidNestedRefs(value: unknown, renameMap: Record<string, string>): unknown {
|
||||
if (typeof value === "string") {
|
||||
let out = value;
|
||||
for (const [oldId, newId] of Object.entries(renameMap)) {
|
||||
out = out.replace(new RegExp(`@\\{${escapeRe(oldId)}([#:!}])`, "g"), `@{${newId}$1`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => rewriteInvalidNestedRefs(v, renameMap));
|
||||
if (value && typeof value === "object") {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
obj[k] = rewriteInvalidNestedRefs(v, renameMap);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Full ref remap: `walkRemap` (whole-string constraint axes + any hex nested
|
||||
* refs) THEN the literal pass for the non-hex ids walkRemap can't see. */
|
||||
export function remapAll(value: unknown, renameMap: Record<string, string>): unknown {
|
||||
return rewriteInvalidNestedRefs(walkRemap(value, renameMap), renameMap);
|
||||
}
|
||||
|
||||
/** Ids that fail the canonical 8-hex shape and therefore need rekeying. */
|
||||
export function findInvalidIds(entries: readonly RepairableEntry[]): string[] {
|
||||
return entries.filter((e) => !isValidId(e.id)).map((e) => e.id);
|
||||
}
|
||||
|
||||
export interface RemapUpdate {
|
||||
id: string;
|
||||
payload?: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the COMPLETE old→new id map, return every entry whose payload or
|
||||
* children actually change under `remapAll` — the rows that need a server
|
||||
* update. Caller passes the rows under their CURRENT id (freshly-created rows
|
||||
* under their new id + original payload, plus the untouched referrers), so the
|
||||
* returned `id` is the one to `update`.
|
||||
*/
|
||||
export function planRemapUpdates(
|
||||
entries: readonly RepairableEntry[],
|
||||
renameMap: Record<string, string>,
|
||||
): RemapUpdate[] {
|
||||
if (Object.keys(renameMap).length === 0) return [];
|
||||
const out: RemapUpdate[] = [];
|
||||
for (const e of entries) {
|
||||
const update: RemapUpdate = { id: e.id };
|
||||
let changed = false;
|
||||
if (e.payload !== undefined) {
|
||||
const next = remapAll(e.payload, renameMap) as Record<string, unknown>;
|
||||
if (JSON.stringify(next) !== JSON.stringify(e.payload)) {
|
||||
update.payload = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (e.children !== undefined) {
|
||||
const next = remapAll(e.children, renameMap) as unknown[];
|
||||
if (JSON.stringify(next) !== JSON.stringify(e.children)) {
|
||||
update.children = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) out.push(update);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Store surface the repair needs — modules + bundles. Dependency-injected so
|
||||
* the orchestrator unit-tests against a mock with no server. `create*` return
|
||||
* the server-minted id. */
|
||||
export interface RepairDeps {
|
||||
listModules(): Promise<RepairableEntry[]>;
|
||||
listBundles(): Promise<RepairableEntry[]>;
|
||||
createModule(entry: RepairableEntry): Promise<{ id: string }>;
|
||||
createBundle(entry: RepairableEntry): Promise<{ id: string }>;
|
||||
updateModule(id: string, payload: Record<string, unknown>): Promise<void>;
|
||||
updateBundle(id: string, children: unknown[]): Promise<void>;
|
||||
deleteModule(id: string): Promise<void>;
|
||||
deleteBundle(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RepairResult {
|
||||
/** Count of rows rekeyed (modules + bundles). 0 = nothing to do. */
|
||||
repaired: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time library repair. Idempotent: a clean library (all valid ids) is a
|
||||
* no-op. Order is create → update → delete so a crash never leaves a dangling
|
||||
* ref — referrers always point at an id that already exists; worst case is a
|
||||
* harmless unreferenced row, cleaned on the next run.
|
||||
*/
|
||||
export async function runLibraryRepair(deps: RepairDeps): Promise<RepairResult> {
|
||||
const modules = await deps.listModules();
|
||||
const bundles = await deps.listBundles();
|
||||
const invalidModules = modules.filter((m) => !isValidId(m.id));
|
||||
const invalidBundles = bundles.filter((b) => !isValidId(b.id));
|
||||
if (invalidModules.length + invalidBundles.length === 0) return { repaired: 0 };
|
||||
|
||||
// 1. Create copies first — the server mints the new ids.
|
||||
const renameMap: Record<string, string> = {};
|
||||
const createdModules: RepairableEntry[] = [];
|
||||
for (const m of invalidModules) {
|
||||
const { id } = await deps.createModule(m);
|
||||
renameMap[m.id] = id;
|
||||
createdModules.push({ ...m, id });
|
||||
}
|
||||
const createdBundles: RepairableEntry[] = [];
|
||||
for (const b of invalidBundles) {
|
||||
const { id } = await deps.createBundle(b);
|
||||
renameMap[b.id] = id;
|
||||
createdBundles.push({ ...b, id });
|
||||
}
|
||||
|
||||
// 2. With the full map, remap referrers + the freshly-created rows (their
|
||||
// payloads may reference other rekeyed ids). Survivors keep their id.
|
||||
const survivorModules = modules.filter((m) => isValidId(m.id));
|
||||
const survivorBundles = bundles.filter((b) => isValidId(b.id));
|
||||
const moduleUpdates = planRemapUpdates([...createdModules, ...survivorModules], renameMap);
|
||||
const bundleUpdates = planRemapUpdates([...createdBundles, ...survivorBundles], renameMap);
|
||||
for (const u of moduleUpdates) {
|
||||
if (u.payload !== undefined) await deps.updateModule(u.id, u.payload);
|
||||
}
|
||||
for (const u of bundleUpdates) {
|
||||
if (u.children !== undefined) await deps.updateBundle(u.id, u.children);
|
||||
}
|
||||
|
||||
// 3. Delete the old (invalid-id) rows last.
|
||||
for (const m of invalidModules) await deps.deleteModule(m.id);
|
||||
for (const b of invalidBundles) await deps.deleteBundle(b.id);
|
||||
|
||||
return { repaired: invalidModules.length + invalidBundles.length };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useCommunityUpdateStore } from "../stores/communityUpdateStore";
|
||||
import { useCascadeStore } from "../cascade/cascade-store";
|
||||
import { useGuidedPublishStore } from "../import-export/guided-publish-store";
|
||||
import { repairLibraryOnce } from "../import-export/uuid-repair-boot";
|
||||
import { api } from "../api/client";
|
||||
import {
|
||||
hashes as libraryHashes,
|
||||
@@ -175,6 +176,20 @@ onMounted(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
// One-time repair of any pre-existing non-8-hex module/bundle ids (a
|
||||
// `coloruni`-style id can't be nested-referenced). Gated + fire-and-forget;
|
||||
// refresh the catalogs + cascade index only if it actually rekeyed rows.
|
||||
void repairLibraryOnce().then((repaired) => {
|
||||
if (repaired <= 0) return;
|
||||
void Promise.all([
|
||||
moduleStore.fetchCatalog().catch(() => undefined),
|
||||
bundleStore.fetchCatalog().catch(() => undefined),
|
||||
]).then(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Cross-check community-installed rows against the community
|
||||
// for newer versions. Runs after the library snapshot is in
|
||||
// place; failures (offline, CORS, server down) clear the
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isValidId, VALID_ID_RE, newShortId } from "./ids";
|
||||
|
||||
describe("isValidId / VALID_ID_RE", () => {
|
||||
it("accepts exactly 8 lowercase hex chars", () => {
|
||||
expect(isValidId("ed1bccf8")).toBe(true);
|
||||
expect(isValidId("00000000")).toBe(true);
|
||||
expect(isValidId("deadbeef")).toBe(true);
|
||||
expect(VALID_ID_RE.test("c010c010")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-hex, wrong length, uppercase, and non-strings", () => {
|
||||
expect(isValidId("coloruni")).toBe(false); // the real bug: has o/r/u/n/i
|
||||
expect(isValidId("ED1BCCF8")).toBe(false); // uppercase
|
||||
expect(isValidId("ed1bccf")).toBe(false); // 7 chars
|
||||
expect(isValidId("ed1bccf80")).toBe(false); // 9 chars
|
||||
expect(isValidId("opt00001")).toBe(false); // option-id shape (o/p/t)
|
||||
expect(isValidId("")).toBe(false);
|
||||
expect(isValidId(undefined)).toBe(false);
|
||||
expect(isValidId(12345678)).toBe(false);
|
||||
});
|
||||
|
||||
it("every newShortId() passes isValidId", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
expect(isValidId(newShortId())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,3 +35,19 @@ export function newShortId(): string {
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical module / bundle id shape — exactly 8 lowercase hex chars,
|
||||
* matching `newShortId()` and the engine's `secrets.token_hex(4)`. Both
|
||||
* the nested-`@{uuid}` ref tokenizer (`engine/syntax/tokenize.py`,
|
||||
* `richTokenize.ts`) and the constraint axis refs key off this shape, so
|
||||
* an id outside it (e.g. a hand-authored slug like `coloruni`) silently
|
||||
* can't be nested-referenced. Option ids are intentionally NOT covered —
|
||||
* they're opaque strings the ref tokenizer never matches.
|
||||
*/
|
||||
export const VALID_ID_RE = /^[0-9a-f]{8}$/;
|
||||
|
||||
/** True when `id` is a canonical 8-lowercase-hex module/bundle id. */
|
||||
export function isValidId(id: unknown): id is string {
|
||||
return typeof id === "string" && VALID_ID_RE.test(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatProbability } from "./percent";
|
||||
|
||||
describe("formatProbability", () => {
|
||||
it("renders zero / non-finite as 0%", () => {
|
||||
expect(formatProbability(0)).toBe("0%");
|
||||
expect(formatProbability(-1)).toBe("0%");
|
||||
expect(formatProbability(NaN)).toBe("0%");
|
||||
});
|
||||
|
||||
it("rounds large values to integers", () => {
|
||||
expect(formatProbability(33.333)).toBe("33%");
|
||||
expect(formatProbability(100)).toBe("100%");
|
||||
expect(formatProbability(10)).toBe("10%");
|
||||
});
|
||||
|
||||
it("shows one trimmed decimal in the 1–10% band", () => {
|
||||
expect(formatProbability(2.5)).toBe("2.5%");
|
||||
expect(formatProbability(5)).toBe("5%"); // 5.0 → trimmed
|
||||
expect(formatProbability(1)).toBe("1%");
|
||||
});
|
||||
|
||||
it("never collapses a nonzero sub-1% probability to 0%", () => {
|
||||
expect(formatProbability(0.76)).toBe("0.76%");
|
||||
expect(formatProbability(0.01)).toBe("0.01%");
|
||||
expect(formatProbability(0.0076)).toBe("0.01%"); // 2dp rounding
|
||||
expect(formatProbability(0.001)).toBe("0.001%"); // grows to 3dp
|
||||
expect(formatProbability(0.5)).toBe("0.5%");
|
||||
});
|
||||
|
||||
it("falls back to <0.01% for vanishingly small probabilities", () => {
|
||||
expect(formatProbability(0.000001)).toBe("<0.01%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Format a wildcard-option probability (a number in 0–100) for display.
|
||||
*
|
||||
* The editor used to render `probabilityFor(o).toFixed(0)`, which rounds
|
||||
* any option below 0.5% to a flat "0%". With many options that's both
|
||||
* common and misleading — a legitimately-weighted option reads as
|
||||
* impossible. This keeps big values clean (integers) and grows decimal
|
||||
* places for small ones so a nonzero probability never shows as "0%".
|
||||
*
|
||||
* 33.333 → "33%" 2.5 → "2.5%" 0.76 → "0.76%"
|
||||
* 0.0076 → "0.01%" 0.001 → "0.001%" 0 → "0%"
|
||||
*/
|
||||
export function formatProbability(pct: number): string {
|
||||
if (!Number.isFinite(pct) || pct <= 0) return "0%";
|
||||
if (pct >= 10) return `${Math.round(pct)}%`;
|
||||
if (pct >= 1) return `${trimZeros(pct.toFixed(1))}%`;
|
||||
// Below 1%: grow precision until the rounded value is nonzero (capped),
|
||||
// so a tiny-but-real weight never collapses to "0%".
|
||||
let dp = 2;
|
||||
while (Number(pct.toFixed(dp)) === 0 && dp < 4) dp++;
|
||||
const v = pct.toFixed(dp);
|
||||
return Number(v) === 0 ? "<0.01%" : `${trimZeros(v)}%`;
|
||||
}
|
||||
|
||||
/** Strip trailing zeros (and a bare trailing dot) from a fixed-decimal string. */
|
||||
function trimZeros(s: string): string {
|
||||
return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
|
||||
}
|
||||
@@ -478,15 +478,6 @@ function cancel() {
|
||||
// after every save would force the user to re-enter the editor for each
|
||||
// tweak. cancel() uses resolveReturnTo for the explicit-exit path.
|
||||
async function save() {
|
||||
if (!isEdit.value || !props.id) {
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Create from Context",
|
||||
detail: "New bundles are created by wrapping modules in the Context widget.",
|
||||
life: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (validationErrors.value.length > 0) {
|
||||
showErrors.value = true;
|
||||
return;
|
||||
@@ -500,7 +491,7 @@ async function save() {
|
||||
// the current token value (which would freeze the colour across
|
||||
// theme switches + future token retunes).
|
||||
const colorOut = color.value === defaultColor.value ? null : color.value;
|
||||
const updated = await store.update(props.id, {
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
description: description.value,
|
||||
color: colorOut,
|
||||
@@ -508,7 +499,22 @@ async function save() {
|
||||
tags: [...tags.value],
|
||||
children: stripBundleChildrenForSave(children.value),
|
||||
content_rating: contentRating.value,
|
||||
});
|
||||
};
|
||||
if (!isEdit.value || !props.id) {
|
||||
// Create a brand-new bundle directly in the manager. (Previously
|
||||
// gated to the Context-wrap flow; the add-child picker builds the
|
||||
// same child shape edit-mode saves, so authoring here is safe.)
|
||||
const created = await store.create(body);
|
||||
bundleBaseline.value = bundleSnapshot();
|
||||
draft.discard();
|
||||
recent.push({ id: created.id, kind: "bundle", name: created.name });
|
||||
setSaveState("saved", 1500);
|
||||
toast.push({ severity: "success", summary: "Created", detail: created.name });
|
||||
// Land in the new bundle's edit page so further tweaks PATCH it.
|
||||
router.replace({ name: "bundles-edit", params: { id: created.id } });
|
||||
return;
|
||||
}
|
||||
const updated = await store.update(props.id, body);
|
||||
original.value = updated;
|
||||
children.value = Array.isArray(updated.children)
|
||||
? updated.children.map((c) => ({ ...(c as Record<string, unknown>) }))
|
||||
@@ -1012,7 +1018,7 @@ const visibleErrors = computed<EditorFieldError[]>(() =>
|
||||
:save-state="saveState"
|
||||
:save-error="saveError"
|
||||
:dirty="dirty"
|
||||
:save-disabled="!isEdit"
|
||||
:save-disabled="validationErrors.length > 0"
|
||||
:errors="visibleErrors"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
|
||||
@@ -126,6 +126,7 @@ interface WildcardOption { id: string; value: string; weight: number; sub_catego
|
||||
interface WildcardPayloadShape {
|
||||
options?: WildcardOption[];
|
||||
sub_categories?: string[];
|
||||
tag_groups?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const name = ref("");
|
||||
@@ -516,6 +517,16 @@ const sourceSubCategories = computed<string[]>(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
// Per-axis grouping (tag_groups) for the matrix headers — colors + orders
|
||||
// the row/col tags by sub-category axis. Empty when the wildcard has no
|
||||
// axes; the matrix then falls back to flat source/target-tinted headers.
|
||||
const sourceGroups = computed<Record<string, string[]>>(
|
||||
() => (sourceWildcard.value?.payload as WildcardPayloadShape)?.tag_groups ?? {},
|
||||
);
|
||||
const targetGroups = computed<Record<string, string[]>>(
|
||||
() => (targetWildcard.value?.payload as WildcardPayloadShape)?.tag_groups ?? {},
|
||||
);
|
||||
|
||||
const targetSubCategories = computed<string[]>(() => {
|
||||
const wc = targetWildcard.value;
|
||||
if (wc) return (wc.payload as WildcardPayloadShape).sub_categories ?? [];
|
||||
@@ -1254,6 +1265,8 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
:model-value="matrix"
|
||||
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
|
||||
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:readonly="refMissing"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
|
||||
@@ -28,6 +28,7 @@ import SelectionToolbar from "../components/SelectionToolbar.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import type { ParsedBulkOption } from "../utils/bulkParse";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { axisHueAt, UNGROUPED_HUE } from "../../components/shared/axis-color";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
|
||||
import { useEditorShortcuts } from "../composables/useEditorShortcuts";
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
collectLibraryWildcardRefs,
|
||||
} from "../utils/library-suggestions";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import { formatProbability } from "../utils/percent";
|
||||
import type {
|
||||
ModuleHistoryEntry,
|
||||
WildcardOption,
|
||||
@@ -293,16 +295,8 @@ const subcatGroups = computed<SubcatGroup[]>(() => {
|
||||
});
|
||||
|
||||
/** Index of the axis a tag belongs to (-1 when ungrouped) — drives the
|
||||
* per-axis chip hue, mirroring OptionRow/PoolSection so a tag reads with
|
||||
* the same colour across every surface. */
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)",
|
||||
"var(--wp-teal, #33d6c6)",
|
||||
"var(--wp-status-modified, #fb923c)",
|
||||
"var(--wp-accent2, #a970ff)",
|
||||
"var(--wp-success, #22c55e)",
|
||||
];
|
||||
|
||||
* per-axis chip hue via the shared `axisHueAt` palette so a tag reads
|
||||
* with the same colour across every surface. */
|
||||
function axisIndexOf(tag: string): number {
|
||||
const axes = Object.keys(tagGroups.value);
|
||||
for (let i = 0; i < axes.length; i++) {
|
||||
@@ -312,9 +306,7 @@ function axisIndexOf(tag: string): number {
|
||||
}
|
||||
|
||||
function tagHue(tag: string): string {
|
||||
const idx = axisIndexOf(tag);
|
||||
if (idx < 0) return "var(--wp-text-dim, var(--wp-text3))";
|
||||
return AXIS_HUES[idx % AXIS_HUES.length];
|
||||
return axisHueAt(axisIndexOf(tag));
|
||||
}
|
||||
|
||||
function chipStyle(tag: string): Record<string, string> {
|
||||
@@ -325,9 +317,8 @@ function chipStyle(tag: string): Record<string, string> {
|
||||
* member tag). Drives the coloured group header + left accent so each axis
|
||||
* reads as a distinct colour cluster (#8). OTHER_AXIS stays neutral. */
|
||||
function axisHue(axis: string): string {
|
||||
if (axis === OTHER_AXIS) return "var(--wp-text-dim, var(--wp-text3))";
|
||||
const idx = Object.keys(tagGroups.value).indexOf(axis);
|
||||
return AXIS_HUES[(idx < 0 ? 0 : idx) % AXIS_HUES.length];
|
||||
if (axis === OTHER_AXIS) return UNGROUPED_HUE;
|
||||
return axisHueAt(Object.keys(tagGroups.value).indexOf(axis));
|
||||
}
|
||||
|
||||
/** How many options carry this tag — the pill's `(count)` badge. */
|
||||
@@ -1738,7 +1729,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
<div class="opt-prob__bar">
|
||||
<div class="opt-prob__fill" :style="{ width: probabilityFor(o) + '%' }" />
|
||||
</div>
|
||||
<span class="opt-prob__value wp-mono">{{ probabilityFor(o).toFixed(0) }}%</span>
|
||||
<span class="opt-prob__value wp-mono">{{ formatProbability(probabilityFor(o)) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -37,6 +37,19 @@ export function create(node: ContextLoopHostNode, inputName: string) {
|
||||
// on mute / bypass. Mirrors WP_VarTo* + WP_Cleaner widget patterns.
|
||||
const nodeMode = reactiveFromGraph(node, () => node.mode ?? 0, Object.is);
|
||||
|
||||
// Read the stock seed + count widgets reactively so the seed modal
|
||||
// preview updates whenever the user edits those widgets.
|
||||
const baseSeed = reactiveFromGraph(
|
||||
node,
|
||||
() => Number((node.widgets ?? []).find((w) => w.name === "seed")?.value ?? 0),
|
||||
Object.is,
|
||||
);
|
||||
const count = reactiveFromGraph(
|
||||
node,
|
||||
() => Number((node.widgets ?? []).find((w) => w.name === "count")?.value ?? 1),
|
||||
Object.is,
|
||||
);
|
||||
|
||||
let host: DomWidgetHost | null = null;
|
||||
|
||||
const wrapper: Component = {
|
||||
@@ -51,6 +64,8 @@ export function create(node: ContextLoopHostNode, inputName: string) {
|
||||
h(ContextLoopWidget, {
|
||||
modelValue: config.value,
|
||||
nodeMode: nodeMode.value,
|
||||
baseSeed: baseSeed.value,
|
||||
count: count.value,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
serializeSeedListConfig,
|
||||
type SeedListConfig,
|
||||
} from "../components/seed-list/types";
|
||||
import type { LiteNodeLike } from "../extension/graph";
|
||||
import { resolveSeedListPreview, type SeriesParams } from "../components/seed-list/preview";
|
||||
import { parseContextLoopConfig } from "../components/context-loop/types";
|
||||
import type { LiteNodeLike, LiteGraphLike } from "../extension/graph";
|
||||
|
||||
const SeedListWidget = defineAsyncComponent(
|
||||
() => import("../components/seed-list/SeedListWidget.vue"),
|
||||
@@ -25,6 +27,7 @@ const SeedListWidget = defineAsyncComponent(
|
||||
|
||||
interface SeedListHostNode extends MountTargetNode, LiteNodeLike {
|
||||
mode?: number;
|
||||
graph?: LiteGraphLike;
|
||||
}
|
||||
|
||||
export function create(node: SeedListHostNode, inputName: string) {
|
||||
@@ -37,6 +40,51 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
// on mute / bypass. Mirrors WP_VarTo* + WP_ContextLoop widget patterns.
|
||||
const nodeMode = reactiveFromGraph(node, () => node.mode ?? 0, Object.is);
|
||||
|
||||
// Read the stock base_seed + count widgets reactively so the seed modal
|
||||
// preview updates whenever the user edits those widgets.
|
||||
const baseSeed = reactiveFromGraph(
|
||||
node,
|
||||
() => Number((node.widgets ?? []).find((w) => w.name === "base_seed")?.value ?? 0),
|
||||
Object.is,
|
||||
);
|
||||
const count = reactiveFromGraph(
|
||||
node,
|
||||
() => Number((node.widgets ?? []).find((w) => w.name === "count")?.value ?? 1),
|
||||
Object.is,
|
||||
);
|
||||
|
||||
// Walk the `loop_config` input to its upstream WP_ContextLoop node and
|
||||
// read its stock `seed` + `count` widgets plus `wp_context_loop_config`
|
||||
// strategy. Returns null when unwired. Reactive so the preview updates
|
||||
// whenever the wire or upstream widgets change (same poll+event model
|
||||
// as the baseSeed/count reads above).
|
||||
function readLoopSeries(): SeriesParams | null {
|
||||
const inputs = (node as { inputs?: Array<{ name?: string; link?: number | null }> }).inputs;
|
||||
if (!inputs) return null;
|
||||
const slot = inputs.find((i) => i.name === "loop_config");
|
||||
if (!slot || slot.link == null) return null;
|
||||
const graph = node.graph;
|
||||
if (!graph) return null;
|
||||
const link = graph.links[slot.link];
|
||||
if (!link) return null;
|
||||
const origin = graph.getNodeById(link.origin_id);
|
||||
if (!origin) return null;
|
||||
const widgets = origin.widgets ?? [];
|
||||
const loopBase = Number(widgets.find((w) => w.name === "seed")?.value ?? 0);
|
||||
const loopCount = Number(widgets.find((w) => w.name === "count")?.value ?? 1);
|
||||
const rawCfg = widgets.find((w) => w.name === "wp_context_loop_config")?.value;
|
||||
const loopCfg = parseContextLoopConfig(typeof rawCfg === "string" ? rawCfg : null);
|
||||
return { base: loopBase, count: loopCount, strategy: loopCfg.strategy };
|
||||
}
|
||||
|
||||
function loopParamsEqual(a: SeriesParams | null, b: SeriesParams | null): boolean {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
return a.base === b.base && a.count === b.count && a.strategy === b.strategy;
|
||||
}
|
||||
|
||||
const loopSeries = reactiveFromGraph(node, readLoopSeries, loopParamsEqual);
|
||||
|
||||
/** Toggle the `disabled` flag on a stock litegraph widget by name.
|
||||
* Litegraph greys the widget out + blocks input when `disabled` is
|
||||
* true. Returns silently when the named widget isn't on the node yet
|
||||
@@ -76,12 +124,27 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
// Re-apply the stock-widget gates whenever a toggle flips.
|
||||
syncStockWidgetGates();
|
||||
}
|
||||
return () =>
|
||||
h(SeedListWidget, {
|
||||
return () => {
|
||||
const localSeries: SeriesParams = {
|
||||
base: baseSeed.value,
|
||||
count: count.value,
|
||||
strategy: config.value.strategy,
|
||||
};
|
||||
const flags = {
|
||||
override_seed: config.value.override_seed,
|
||||
override_count: config.value.override_count,
|
||||
override_strategy: config.value.override_strategy,
|
||||
};
|
||||
const effective = resolveSeedListPreview(localSeries, flags, loopSeries.value);
|
||||
return h(SeedListWidget, {
|
||||
modelValue: config.value,
|
||||
nodeMode: nodeMode.value,
|
||||
baseSeed: effective.baseSeed,
|
||||
count: effective.count,
|
||||
previewStrategy: effective.strategy,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ _DEFAULTS = {
|
||||
"bypass": False,
|
||||
"iteration_internal": True,
|
||||
"total_internal": True,
|
||||
"seed_locks": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +34,15 @@ _DEFAULTS = {
|
||||
'"iteration_var_name": "idx", "bypass": true}',
|
||||
{"strategy": "sequential", "override_seed": True,
|
||||
"iteration_var_name": "idx", "bypass": True,
|
||||
"iteration_internal": True, "total_internal": True},
|
||||
"iteration_internal": True, "total_internal": True,
|
||||
"seed_locks": {}},
|
||||
),
|
||||
(
|
||||
'{"strategy": "wat", "override_seed": true}',
|
||||
{"strategy": "hash_index", "override_seed": True,
|
||||
"iteration_var_name": "iteration", "bypass": False,
|
||||
"iteration_internal": True, "total_internal": True},
|
||||
"iteration_internal": True, "total_internal": True,
|
||||
"seed_locks": {}},
|
||||
),
|
||||
('{"iteration_var_name": " "}', _DEFAULTS),
|
||||
(
|
||||
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
{
|
||||
"derive": [
|
||||
{
|
||||
"base": 0,
|
||||
"count": 1,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 1,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 1,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 4,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 4,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
0,
|
||||
1000003,
|
||||
2000006,
|
||||
3000009
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 4,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
622917770156225,
|
||||
931467140767837,
|
||||
186764082477291,
|
||||
1058756667342483
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 12,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 12,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
0,
|
||||
1000003,
|
||||
2000006,
|
||||
3000009,
|
||||
4000012,
|
||||
5000015,
|
||||
6000018,
|
||||
7000021,
|
||||
8000024,
|
||||
9000027,
|
||||
10000030,
|
||||
11000033
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 0,
|
||||
"count": 12,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
622917770156225,
|
||||
931467140767837,
|
||||
186764082477291,
|
||||
1058756667342483,
|
||||
65735575284520,
|
||||
8414473785724,
|
||||
638605282230683,
|
||||
1002735474326755,
|
||||
583013065760719,
|
||||
407657353091809,
|
||||
328666774886061,
|
||||
536389294218733
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 1,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
42
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 1,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
42
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 1,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
42
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 4,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
42,
|
||||
43,
|
||||
44,
|
||||
45
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 4,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
42,
|
||||
1000045,
|
||||
2000048,
|
||||
3000051
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 4,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
921162604278642,
|
||||
554502062959870,
|
||||
404171251152566,
|
||||
1043146765877736
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 12,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
42,
|
||||
43,
|
||||
44,
|
||||
45,
|
||||
46,
|
||||
47,
|
||||
48,
|
||||
49,
|
||||
50,
|
||||
51,
|
||||
52,
|
||||
53
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 12,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
42,
|
||||
1000045,
|
||||
2000048,
|
||||
3000051,
|
||||
4000054,
|
||||
5000057,
|
||||
6000060,
|
||||
7000063,
|
||||
8000066,
|
||||
9000069,
|
||||
10000072,
|
||||
11000075
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 42,
|
||||
"count": 12,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
921162604278642,
|
||||
554502062959870,
|
||||
404171251152566,
|
||||
1043146765877736,
|
||||
927201806787560,
|
||||
832860729725391,
|
||||
92569956950090,
|
||||
470827887926830,
|
||||
381328814372330,
|
||||
860962065276667,
|
||||
52002127413976,
|
||||
947032504488781
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 1,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
593560241826833
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 1,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
593560241826833
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 1,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
593560241826833
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 4,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
593560241826833,
|
||||
593560241826834,
|
||||
593560241826835,
|
||||
593560241826836
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 4,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
593560241826833,
|
||||
593560242826836,
|
||||
593560243826839,
|
||||
593560244826842
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 4,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
494380373716990,
|
||||
840378297563533,
|
||||
93532444899055,
|
||||
539231446213217
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 12,
|
||||
"strategy": "sequential",
|
||||
"derived": [
|
||||
593560241826833,
|
||||
593560241826834,
|
||||
593560241826835,
|
||||
593560241826836,
|
||||
593560241826837,
|
||||
593560241826838,
|
||||
593560241826839,
|
||||
593560241826840,
|
||||
593560241826841,
|
||||
593560241826842,
|
||||
593560241826843,
|
||||
593560241826844
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 12,
|
||||
"strategy": "prime_stride",
|
||||
"derived": [
|
||||
593560241826833,
|
||||
593560242826836,
|
||||
593560243826839,
|
||||
593560244826842,
|
||||
593560245826845,
|
||||
593560246826848,
|
||||
593560247826851,
|
||||
593560248826854,
|
||||
593560249826857,
|
||||
593560250826860,
|
||||
593560251826863,
|
||||
593560252826866
|
||||
]
|
||||
},
|
||||
{
|
||||
"base": 593560241826833,
|
||||
"count": 12,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
494380373716990,
|
||||
840378297563533,
|
||||
93532444899055,
|
||||
539231446213217,
|
||||
863354412581016,
|
||||
207988599354989,
|
||||
379472665123606,
|
||||
993428677821021,
|
||||
673857473725553,
|
||||
991956353347512,
|
||||
155512136604776,
|
||||
293362051898727
|
||||
]
|
||||
}
|
||||
],
|
||||
"applyLocks": {
|
||||
"base": 100,
|
||||
"count": 5,
|
||||
"strategy": "hash_index",
|
||||
"derived": [
|
||||
382771060824834,
|
||||
810176757932863,
|
||||
326574196932401,
|
||||
552663342010863,
|
||||
547418473581964
|
||||
],
|
||||
"locks": {
|
||||
"1": 7,
|
||||
"3": 9
|
||||
},
|
||||
"locked": [
|
||||
382771060824834,
|
||||
7,
|
||||
326574196932401,
|
||||
9,
|
||||
547418473581964
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from engine.seed_derive import MAX_SAFE_SEED, apply_seed_locks, derive_loop_seeds
|
||||
|
||||
|
||||
def test_apply_seed_locks_overrides_only_locked_indices():
|
||||
derived = [10, 20, 30, 40]
|
||||
assert apply_seed_locks(derived, {1: 999, 3: 888}) == [10, 999, 30, 888]
|
||||
|
||||
|
||||
def test_apply_seed_locks_empty_is_identity():
|
||||
assert apply_seed_locks([10, 20, 30], {}) == [10, 20, 30]
|
||||
|
||||
|
||||
def test_apply_seed_locks_masks_locked_values_to_50_bits():
|
||||
over = MAX_SAFE_SEED + 5
|
||||
assert apply_seed_locks([0, 0], {0: over}) == [over & MAX_SAFE_SEED, 0]
|
||||
|
||||
|
||||
def test_apply_seed_locks_ignores_out_of_range_indices():
|
||||
assert apply_seed_locks([10, 20], {5: 999}) == [10, 20]
|
||||
|
||||
|
||||
def test_apply_seed_locks_over_real_derivation():
|
||||
derived = derive_loop_seeds(42, 4, "sequential") # [42, 43, 44, 45]
|
||||
assert apply_seed_locks(derived, {2: 7}) == [42, 43, 7, 45]
|
||||
|
||||
|
||||
def test_corpus_matches_engine():
|
||||
corpus = json.loads(pathlib.Path("tests/fixtures/seed-derive-corpus.json").read_text())
|
||||
for c in corpus["derive"]:
|
||||
assert derive_loop_seeds(c["base"], c["count"], c["strategy"]) == c["derived"]
|
||||
@@ -66,6 +66,35 @@ async def test_create_with_color_and_children(wp_client):
|
||||
assert body["payload_hash"] != ""
|
||||
|
||||
|
||||
async def test_create_preserves_duplicate_children(wp_client):
|
||||
"""Multi-instance bundles intentionally repeat a module id (e.g. the
|
||||
Universal Color used 3× as garment/hair/eyes). The create path must
|
||||
keep every occurrence — per-instance disambiguation happens at
|
||||
Context-insert time, not at bundle-store time."""
|
||||
dup = {"id": "c0107a11", "type": "wildcard", "payload": {"options": []}}
|
||||
resp = await wp_client.post("/wp/api/bundles", json={
|
||||
"name": "color_triple",
|
||||
"children": [dict(dup), dict(dup), dict(dup)],
|
||||
})
|
||||
assert resp.status == 201
|
||||
body = await resp.json()
|
||||
assert [c["id"] for c in body["children"]] == ["c0107a11"] * 3
|
||||
|
||||
|
||||
async def test_update_preserves_duplicate_children(wp_client):
|
||||
created = await (await wp_client.post("/wp/api/bundles", json={
|
||||
"name": "color_triple_upd",
|
||||
"children": [],
|
||||
})).json()
|
||||
dup = {"id": "c0107a22", "type": "wildcard", "payload": {"options": []}}
|
||||
resp = await wp_client.put(f"/wp/api/bundles/{created['id']}", json={
|
||||
"children": [dict(dup), dict(dup)],
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert [c["id"] for c in body["children"]] == ["c0107a22", "c0107a22"]
|
||||
|
||||
|
||||
async def test_create_missing_name_returns_400(wp_client):
|
||||
resp = await wp_client.post("/wp/api/bundles", json={
|
||||
"color": "#FB7185",
|
||||
|
||||
@@ -199,10 +199,13 @@ async def test_bundle_update_keeps_own_name_unsuffixed(wp_client):
|
||||
assert (await resp.json())["name"] == "qa_self"
|
||||
|
||||
|
||||
# ─── Fix 12: bundle children dedup ───────────────────────────────────
|
||||
# ─── bundle children: duplicates preserved ───────────────────────────
|
||||
# Reverses the former "Fix 12" dedup: multi-instance bundles repeat a
|
||||
# module id on purpose (one wildcard used as garment/hair/eyes), and
|
||||
# per-instance disambiguation happens at Context-insert time, not here.
|
||||
|
||||
|
||||
async def test_bundle_create_dedupes_children_by_id(wp_client):
|
||||
async def test_bundle_create_preserves_duplicate_children(wp_client):
|
||||
create = await _create_wildcard(wp_client, name="qa_shared_child")
|
||||
mid = (await create.json())["id"]
|
||||
|
||||
@@ -216,10 +219,10 @@ async def test_bundle_create_dedupes_children_by_id(wp_client):
|
||||
})
|
||||
assert resp.status == 201
|
||||
body = await resp.json()
|
||||
assert len(body["children"]) == 1
|
||||
assert len(body["children"]) == 3
|
||||
|
||||
|
||||
async def test_bundle_update_dedupes_children_by_id(wp_client):
|
||||
async def test_bundle_update_preserves_duplicate_children(wp_client):
|
||||
create = await _create_wildcard(wp_client, name="qa_shared_child_2")
|
||||
mid = (await create.json())["id"]
|
||||
bun = await wp_client.post("/wp/api/bundles", json={"name": "qa_dup_children_put"})
|
||||
@@ -233,4 +236,4 @@ async def test_bundle_update_dedupes_children_by_id(wp_client):
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert len(body["children"]) == 1
|
||||
assert len(body["children"]) == 2
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Node-level tests for WP_ContextLoop seed_locks parsing + execution.
|
||||
|
||||
Covers _parse_config seed_locks extraction (int-key coercion, defaults,
|
||||
malformed-entry dropping) and the execute path with seed locks applied
|
||||
when override_seed is on.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from wp_nodes.context_loop import WPContextLoop, _parse_config
|
||||
|
||||
|
||||
def test_parse_config_reads_seed_locks_as_int_map():
|
||||
cfg = _parse_config(json.dumps({"seed_locks": {"1": 999, "3": 888}}))
|
||||
assert cfg["seed_locks"] == {1: 999, 3: 888}
|
||||
|
||||
|
||||
def test_parse_config_defaults_seed_locks_empty():
|
||||
assert _parse_config("{}")["seed_locks"] == {}
|
||||
|
||||
|
||||
def test_parse_config_drops_malformed_seed_locks():
|
||||
cfg = _parse_config(json.dumps({"seed_locks": {"x": "nope", "2": 5}}))
|
||||
assert cfg["seed_locks"] == {2: 5}
|
||||
|
||||
|
||||
def test_execute_applies_seed_locks_when_override_on():
|
||||
raw = json.dumps({"strategy": "sequential", "override_seed": True,
|
||||
"seed_locks": {"2": 7}})
|
||||
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
|
||||
payloads = out.values[0]
|
||||
assert payloads[2].internals["__wp_seed_override__"] == 7
|
||||
assert payloads[0].internals["__wp_seed_override__"] == 42
|
||||
assert payloads[2].internals["__wp_loop_seeds__"] == [42, 43, 7, 45]
|
||||
@@ -28,6 +28,7 @@ _DEFAULTS = {
|
||||
"override_seed": False,
|
||||
"override_count": False,
|
||||
"override_strategy": False,
|
||||
"seed_locks": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +90,7 @@ def test_parse_config_full_valid_payload():
|
||||
"override_seed": True,
|
||||
"override_count": True,
|
||||
"override_strategy": True,
|
||||
"seed_locks": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -501,3 +503,39 @@ def test_schema_seed_output_is_list_typed():
|
||||
[seed_out] = schema.outputs
|
||||
assert seed_out.is_output_list is True
|
||||
assert seed_out.type_name == "INT"
|
||||
|
||||
|
||||
# ---------------------------------------------------- seed_locks parsing / execute
|
||||
|
||||
|
||||
def test_parse_config_reads_seed_locks():
|
||||
cfg = _parse_config(json.dumps({"seed_locks": {"0": 5, "2": 9}}))
|
||||
assert cfg["seed_locks"] == {0: 5, 2: 9}
|
||||
|
||||
|
||||
def test_parse_config_defaults_seed_locks_empty():
|
||||
assert _parse_config("{}")["seed_locks"] == {}
|
||||
|
||||
|
||||
def test_parse_config_drops_malformed_seed_locks():
|
||||
cfg = _parse_config(json.dumps({"seed_locks": {"x": "no", "1": 9}}))
|
||||
assert cfg["seed_locks"] == {1: 9}
|
||||
|
||||
|
||||
def test_execute_output_reflects_seed_locks():
|
||||
# sequential base 10, count 3 -> [10, 11, 12]; lock index 1 -> 77
|
||||
raw = json.dumps({
|
||||
"strategy": "sequential",
|
||||
"override_seed": False,
|
||||
"override_count": False,
|
||||
"override_strategy": False,
|
||||
"seed_locks": {"1": 77},
|
||||
})
|
||||
out = WPSeedList.execute(
|
||||
base_seed=10,
|
||||
count=3,
|
||||
wp_seed_list_config=raw,
|
||||
loop_config=None,
|
||||
)
|
||||
seeds = _seeds(out)
|
||||
assert seeds == [10, 77, 12]
|
||||
|
||||
+6
-29
@@ -23,31 +23,6 @@ _UPDATABLE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_children(children: list[dict]) -> list[dict]:
|
||||
"""Drop second+ occurrences of any child sharing an ``id`` with an
|
||||
earlier child in the same list.
|
||||
|
||||
Without this guard the same module could appear twice (or three,
|
||||
four) times in ``children[]`` and every propagation pass would
|
||||
rewrite every copy identically — harmless but bloats the bundle
|
||||
payload and confuses the SPA bundle preview (two "framing" cards,
|
||||
same id). Order is preserved; the first occurrence wins.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
out: list[dict] = []
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
out.append(child)
|
||||
continue
|
||||
cid = child.get("id")
|
||||
if isinstance(cid, str) and cid in seen:
|
||||
continue
|
||||
if isinstance(cid, str):
|
||||
seen.add(cid)
|
||||
out.append(child)
|
||||
return out
|
||||
|
||||
|
||||
def _auto_suffix_bundle_name(repo: BundleRepository, name: str) -> str:
|
||||
"""If ``name`` is already taken by another bundle in the library,
|
||||
append ``" (copy)"`` / ``" (copy 2)"`` / etc. until a free name is
|
||||
@@ -401,8 +376,10 @@ async def create_bundle(request: web.Request) -> web.Response:
|
||||
if err is not None:
|
||||
return json_error(err, status=400)
|
||||
if isinstance(children_raw, list):
|
||||
normalised = _normalise_bundle_children(children_raw)
|
||||
children = _dedupe_children(normalised)
|
||||
# Keep duplicate child ids — multi-instance bundles
|
||||
# (e.g. one wildcard used 3×) repeat an id on purpose;
|
||||
# per-instance `_uid` is stamped at Context-insert time.
|
||||
children = _normalise_bundle_children(children_raw)
|
||||
else:
|
||||
children = []
|
||||
unique_name = _auto_suffix_bundle_name(repo, body["name"])
|
||||
@@ -522,8 +499,8 @@ async def update_bundle(request: web.Request) -> web.Response:
|
||||
if err is not None:
|
||||
return json_error(err, status=409)
|
||||
if isinstance(patch["children"], list):
|
||||
normalised = _normalise_bundle_children(patch["children"])
|
||||
patch["children"] = _dedupe_children(normalised)
|
||||
# Preserve duplicates (see create path) — no dedupe.
|
||||
patch["children"] = _normalise_bundle_children(patch["children"])
|
||||
# Auto-suffix rename collisions only when the new name actually
|
||||
# changed AND collides with a DIFFERENT row. PUTting a row with
|
||||
# its own current name must be a no-op for the suffix logic.
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
|
||||
from engine.seed_derive import derive_loop_seeds
|
||||
from engine.seed_derive import apply_seed_locks, derive_loop_seeds
|
||||
from wp_nodes.types import (
|
||||
ContextLoopConfigInput,
|
||||
ContextLoopWidgetInput,
|
||||
@@ -39,6 +39,7 @@ def _parse_config(raw: str) -> dict[str, object]:
|
||||
"bypass": False,
|
||||
"iteration_internal": True,
|
||||
"total_internal": True,
|
||||
"seed_locks": {},
|
||||
}
|
||||
if not raw or not isinstance(raw, str):
|
||||
return defaults
|
||||
@@ -58,6 +59,15 @@ def _parse_config(raw: str) -> dict[str, object]:
|
||||
out["bypass"] = bool(parsed.get("bypass", False))
|
||||
out["iteration_internal"] = bool(parsed.get("iteration_internal", True))
|
||||
out["total_internal"] = bool(parsed.get("total_internal", True))
|
||||
locks_raw = parsed.get("seed_locks", {})
|
||||
locks: dict[int, int] = {}
|
||||
if isinstance(locks_raw, dict):
|
||||
for k, v in locks_raw.items():
|
||||
try:
|
||||
locks[int(k)] = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out["seed_locks"] = locks
|
||||
return out
|
||||
|
||||
|
||||
@@ -136,6 +146,7 @@ class WPContextLoop(io.ComfyNode):
|
||||
bypass = bool(cfg["bypass"])
|
||||
iteration_internal = bool(cfg["iteration_internal"])
|
||||
total_internal = bool(cfg["total_internal"])
|
||||
seed_locks: dict[int, int] = cfg["seed_locks"] # type: ignore[assignment]
|
||||
|
||||
# Resolve effective count. `bypass` collapses to single-run while
|
||||
# preserving the list-shape output contract — downstream still
|
||||
@@ -143,7 +154,10 @@ class WPContextLoop(io.ComfyNode):
|
||||
effective_count = 1 if bypass else max(1, int(count))
|
||||
has_override = override_seed
|
||||
derived = (
|
||||
derive_loop_seeds(int(seed), effective_count, strategy)
|
||||
apply_seed_locks(
|
||||
derive_loop_seeds(int(seed), effective_count, strategy),
|
||||
seed_locks,
|
||||
)
|
||||
if has_override else None
|
||||
)
|
||||
# Build the loop_config payload once so the same dict drives both
|
||||
|
||||
+16
-2
@@ -35,7 +35,7 @@ from typing import Any
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
|
||||
from engine.seed_derive import MAX_SAFE_SEED, derive_loop_seeds
|
||||
from engine.seed_derive import MAX_SAFE_SEED, apply_seed_locks, derive_loop_seeds
|
||||
from wp_nodes.types import ContextLoopConfigInput, SeedListConfigInput
|
||||
|
||||
_STRATEGIES = ("hash_index", "sequential", "prime_stride")
|
||||
@@ -60,6 +60,7 @@ def _parse_config(raw: Any) -> dict[str, Any]:
|
||||
"override_seed": False,
|
||||
"override_count": False,
|
||||
"override_strategy": False,
|
||||
"seed_locks": {},
|
||||
}
|
||||
if raw is None:
|
||||
return defaults
|
||||
@@ -97,6 +98,16 @@ def _parse_config(raw: Any) -> dict[str, Any]:
|
||||
out["override_count"] = legacy
|
||||
if not has_new_strategy:
|
||||
out["override_strategy"] = legacy
|
||||
|
||||
locks_raw = parsed.get("seed_locks", {})
|
||||
locks: dict[int, int] = {}
|
||||
if isinstance(locks_raw, dict):
|
||||
for k, v in locks_raw.items():
|
||||
try:
|
||||
locks[int(k)] = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out["seed_locks"] = locks
|
||||
return out
|
||||
|
||||
|
||||
@@ -236,5 +247,8 @@ class WPSeedList(io.ComfyNode):
|
||||
override_strategy=override_strategy,
|
||||
loop_config=loop_config,
|
||||
)
|
||||
seeds = derive_loop_seeds(resolved_base, resolved_count, resolved_strategy)
|
||||
seeds = apply_seed_locks(
|
||||
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
|
||||
cfg["seed_locks"],
|
||||
)
|
||||
return io.NodeOutput(seeds)
|
||||
|
||||
Reference in New Issue
Block a user