Compare commits

...
9 Commits
Author SHA1 Message Date
semantic-release-bot 5b5c635be6 chore(release): 2.4.0 [skip ci] 2026-06-18 02:53:08 +00:00
Dumitru Picalau ccb8392219 Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug
fix: inline branch weights + wildcard pool UI + debug warnings
2026-06-18 05:52:28 +03:00
DumiFlex d8c20728cb fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs
Three pool/UI fixes. (B) The MOD dot now lights on filter / pick-range / exclude-null changes — the category_filter check ran nonEmptyArr() on a string and silently missed, so toggling the pool filter never marked the instance modified. (C/D) The wildcard card subtitle shows 'X of Y options' when a filter narrows the pool, and '· picks N[-M]' when the instance multi-picks. (F) A nested @{uuid} ref to a live-library wildcard not in the chain no longer shows a false BROKEN REF — the Context node DB-expands its catalog at run, so it resolves; only refs absent from BOTH chain and library still flag (and those already emit unknown_ref in the debug panel). Bumps the extension bundle budget 431->432 KB.
2026-06-18 05:42:34 +03:00
DumiFlex 0e9e5db486 feat(engine): emit a constraint source-missing debug warning
A constraint with both ends absent from the chain emitted only the target-focused constraint_never_applied warning, so WP Debug showed one issue while the editor row flagged both source AND target missing. The pipeline now also emits constraint_source_missing (with a matching DebugViewer label) when the constraint's source wildcard isn't in the chain, so both ends surface in the debug panel.
2026-06-18 05:37:55 +03:00
DumiFlex a9516996b4 fix(engine): apply N:: branch weights in simple inline {a|b|c} choices
Simple inline picks resolved a branch uniformly and never stripped the N:: weight prefix, so {2::a|b} could emit the literal 2::a and {2::|, long shadows} leaked 2::. _resolve_inline_pick now strips the prefix and weights the pick when any branch carries one, staying byte-for-byte seed-stable for unweighted choices. Mirrored in the TS preview resolver (expandInlineChoices); multi-pick blocks already handled weights.
2026-06-18 05:33:24 +03:00
semantic-release-bot 02a046321f chore(release): 2.3.1 [skip ci] 2026-06-18 01:06:25 +00:00
Dumitru Picalau 6a5a8d318e Merge pull request #3 from DumiFlex/fix/instance-modal-scroll
fix(extension): scroll instance edit modals with pinned header + footer
2026-06-18 04:05:45 +03:00
DumiFlex a19e5f4500 Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll
# Conflicts:
#	scripts/check-bundle-size.mjs
2026-06-18 03:44:54 +03:00
DumiFlex c43bff0488 fix(extension): scroll instance edit modals with pinned header + footer
Every per-instance edit modal (wildcard, fixed-values, derivation, combine, constraint, bundle) now scrolls its body when content overflows, with the header pinned to the top and a sticky dock pinned to the bottom (runtime + footer, or target-reach + footer for constraints, which have no runtime) so Save is always reachable. The long inner lists — options, values, rules, exceptions — each scroll independently with sticky column headers. Constraint's target-reach moves to last. Compact + right-padded the exceptions delete button. Bumps the extension bundle budget 430->431 KB for the added modal CSS.
2026-06-18 03:23:24 +03:00
25 changed files with 392 additions and 47 deletions
+29
View File
@@ -1,3 +1,32 @@
## 🎉 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)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
</details>
## 🎉 Wildcard Pipeline 2.3.1
📖 [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>
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
</details>
## 🎉 Wildcard Pipeline 2.3.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)
+38
View File
@@ -613,6 +613,44 @@ class PipelineEngine:
# otherwise render as plain text).
"message": message,
})
# Source-side companion warning. The `never_applied` message
# above is target-focused; when the constraint's SOURCE
# wildcard is ALSO absent from the chain, surface that
# distinctly so the Debug viewer shows BOTH ends (mirrors the
# editor's separate "source missing" / "target missing" row
# badges). A constraint registers by its own module position,
# so it can sit in the bucket with neither end present.
source_uuid = c.get("source_wildcard_id", "")
if source_uuid and not _target_present_in_chain(
source_uuid, modules, catalog,
):
src_row = (
catalog.get(source_uuid) if isinstance(catalog, dict) else None
)
source_name = (
(src_row.get("name", "") or "")
if isinstance(src_row, dict) else ""
)
src_suffix = _build_ref_name_suffix(source_name)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
"type": "constraint_source_missing",
"severity": "warn",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"source_wildcard_id": source_uuid,
},
"message": (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
f"this chain, so it never registers a source pick."
),
})
# SP3 partial-reach finalisation: a constraint that DID fire but
# whose `next N` / `pick` selector matched FEWER occurrences than
+13 -2
View File
@@ -169,8 +169,19 @@ def _resolve_inline_pick(
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
chosen_idx = ctx.rng.randrange(len(branches))
chosen = branches[chosen_idx]
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
# honors. Strip the prefix off every branch so it never leaks into output;
# only switch to weighted selection when a branch actually carries a weight,
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
# randrange path — changing the draw would shift every existing seed).
values = [_strip_branch_weight(b) for b in branches]
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
if has_weights:
weights = [_parse_branch_weight(b) for b in branches]
chosen_idx = _weighted_pick_index(weights, ctx.rng)
else:
chosen_idx = ctx.rng.randrange(len(branches))
chosen = values[chosen_idx]
if not chosen:
return ""
nested_tokens = tokenize_text(chosen)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.3.0"
version = "2.4.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+15 -8
View File
@@ -157,14 +157,21 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
// the SP3 constraint feature.
// Bumped 430 -> 431 KB on 2026-06-18 for the editor robustness pass:
// RichTextInput's scoped renderEpoch rebuild (nested-ref picker crash fix)
// + useEditorDraft flush-on-unmount/unload + ErrorBoundary copy, plus the
// clipboard-shield wired into _shared.ts + main.ts (Ctrl-A/C/V/X guard on
// editable widget inputs). ~0.3 KB net gzip across the extension entry +
// shared chunks. Landed on the bulk-editor branch (the bulk UI itself is
// manager-only and adds nothing to js/).
const TOTAL_LIMIT = 431 * 1024; // 431 KB
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
// exceptions trash, across all 6 edit modals.
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
// 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
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
+43 -7
View File
@@ -29,6 +29,7 @@ import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-c
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
import {
cascadeRestoreForBundle,
@@ -2027,6 +2028,9 @@ const conflicts = computed<Conflict[]>(() => {
props.downstreamWildcardUuids,
props.downstreamNestedReachUuids,
pairOrphanByUid,
// Live-library uuids — a nested @{} ref to any of these resolves at run
// time (DB-expanded catalog), so it must not read as a broken ref.
new Set(Object.keys(libraryHashes.value ?? {})),
);
// Filter by user's validation-strictness preference. The accessor
// reads from the same module-level state map the panel onChange
@@ -2806,7 +2810,16 @@ function isModified(m: ModuleEntry): boolean {
if (nonEmptyObj(inst.option_weights)) return true;
if (inst.mode && inst.mode !== "random") return true;
if (inst.pinned_option_id) return true;
if (nonEmptyArr(inst.category_filter)) return true;
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
// never matched it, so toggling the pool filter never lit the dot.
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
if (inst.exclude_null) return true;
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
// is the library default; anything else is a behavioural override.
if (inst.pick_min != null && inst.pick_min !== 1) return true;
if (inst.pick_max != null && inst.pick_max !== 1) return true;
if (inst.pick_independent) return true;
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
return false;
case "fixed_values":
// Library-tracked: `values_overrides` non-empty = user edited entries,
@@ -3181,14 +3194,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
// Card summary respects the per-instance binding override so a
// renamed wildcard reads as `$<override>` on the canvas. Engine
// applies the same precedence at run time.
const inst = m.instance?.variable_binding;
const instBind = m.instance?.variable_binding;
const payloadBinding = (p.var_binding as string | undefined)?.trim();
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
const opts = Array.isArray(p.options) ? p.options.length : 0;
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
return opts
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
: [head];
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
const total = allOpts.length;
if (!total) return [head];
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
const multiActive = pmax > 1;
// "X of Y options" only when a filter / enabled-subset actually narrows
// the pool — a plain count when nothing is filtering (keeps the common
// case clean). The null-option drop in multi mode alone never triggers it.
const hasFilter =
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
Array.isArray(inst.enabled_options);
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
const tokens: SummaryToken[] = [
head,
lit(
hasFilter && eligible < total
? ` · ${eligible} of ${total} options`
: ` · ${total} option${total === 1 ? "" : "s"}`,
),
];
// Multi-pick hint — surface when the instance resolves more than one.
if (pmax > 1) {
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
}
return tokens;
}
case "combine": {
// Same per-instance precedence as wildcard: rebinding via the v2
@@ -20,6 +20,12 @@
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
/* Pinned to the top of the scrolling modal body so the title + close
* stay visible while the content scrolls. Opaque (gradient bottoms out
* at --wp-bg2) so scrolled content doesn't bleed through. */
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
@@ -102,6 +102,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
:library-color="libraryColor"
@update="(patch) => emit('update', patch)"
/>
<div class="wp-bdm__dock">
<RuntimeSection
:lock-state="lockState"
:internal-state="internalState"
@@ -157,12 +158,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
Save
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
.wp-bdm__title-block { flex: 1; min-width: 0; }
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -175,6 +177,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
.wp-bdm__close:hover { color: var(--wp-text); }
.wp-bdm__close .pi { font-size: 12px; }
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.wp-bdm__spa-link { display: inline-flex; align-items: center; gap: 5px; font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); text-decoration: none; }
.wp-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
@@ -109,6 +109,7 @@ function onSpaClick(): void {
:sibling-vars="siblingVars"
@update="onUpdate"
/>
<div class="wp-cbm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-cbm__foot">
@@ -147,6 +148,7 @@ function onSpaClick(): void {
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -160,7 +162,7 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -168,6 +170,12 @@ function onSpaClick(): void {
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). Every modal in this family shares
* the same accent-tinted gradient + chip + close button. */
.wp-cbm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-cbm__foot {
display: flex;
align-items: center;
@@ -691,14 +691,6 @@ function onSpaClick(): void {
:stranded="hasDangling"
@update="onUpdate"
/>
<TargetReachSection
:model-value="targetSelect"
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<ExceptionsSection
:module="module"
:source-values="sourceValues"
@@ -712,6 +704,15 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<div class="wp-cnm__dock">
<TargetReachSection
:model-value="targetSelect"
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<footer class="wp-cnm__foot">
<a
v-if="isLibraryTracked"
@@ -748,6 +749,7 @@ function onSpaClick(): void {
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
</footer>
</div>
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
from the community before any network/install side effect. -->
@@ -772,13 +774,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-cnm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-cnm__foot {
display: flex;
align-items: center;
@@ -304,6 +304,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
<section class="ex" data-test="ex-section">
<div class="ex__label">Exceptions</div>
<div class="ex__list">
<div
v-for="(exc, i) in libraryExceptions"
:key="libKey(exc)"
@@ -551,6 +552,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
><i class="pi pi-trash" /></button>
</template>
</div>
</div>
<button
v-if="!stranded"
@@ -581,11 +583,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
color: var(--wp-text-dim, var(--wp-text3));
margin-bottom: 8px;
}
.ex__list {
/* Long exception lists scroll in place; the label + add button stay put. */
max-height: 34vh;
overflow-y: auto;
}
.ex__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
/* right padding keeps the trash off the section edge + the list
* scrollbar when the exceptions list scrolls. */
padding: 4px 8px 4px 0;
font: 11px var(--wp-font-mono);
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
}
@@ -801,17 +810,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 20px;
height: 20px;
padding: 0;
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
border: 1px solid var(--wp-border);
background: transparent;
border: 0;
border-radius: 4px;
color: var(--wp-text-dim, var(--wp-text3));
cursor: pointer;
font-size: 11px;
flex-shrink: 0;
transition: background 0.12s, border-color 0.12s, color 0.12s;
transition: background 0.12s, color 0.12s;
}
.ex__trash:hover {
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
@@ -160,6 +160,7 @@ function onSpaClick(): void {
:via-option-pairs="viaOptionPairs"
@update="onUpdate"
/>
<div class="wp-dvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-dvm__foot">
@@ -198,6 +199,7 @@ function onSpaClick(): void {
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -211,13 +213,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-dvm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-dvm__foot {
display: flex;
align-items: center;
@@ -693,6 +693,9 @@ function opUsesValue(op: string | undefined): boolean {
display: flex;
flex-direction: column;
gap: 6px;
/* Long rule lists scroll in place so identity / footer stay visible. */
max-height: 44vh;
overflow-y: auto;
}
/* Rule card */
@@ -66,6 +66,7 @@ function onSpaClick(): void {
<IdentitySection :module="module" @update="onUpdate" />
<ValuesSection :module="module" @update="onUpdate" />
<div class="wp-fvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-fvm__foot">
@@ -105,6 +106,7 @@ function onSpaClick(): void {
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -118,13 +120,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-fvm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-fvm__foot {
display: flex;
align-items: center;
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
overflow: hidden;
/* Long value lists scroll in place; the header row stays pinned. */
max-height: 42vh;
overflow-y: auto;
}
.vals__head {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: 22px 180px 1fr 28px 28px;
gap: 10px;
@@ -101,6 +101,7 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
<div class="wp-wcm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-wcm__foot">
@@ -150,6 +151,7 @@ function onSpaClick(): void {
Save
</button>
</footer>
</div>
</div>
</template>
@@ -163,7 +165,7 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -171,6 +173,12 @@ function onSpaClick(): void {
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). Adopted from InjectorBindingModal
* so every per-instance edit modal reads as one design family. */
.wp-wcm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-wcm__foot {
display: flex;
align-items: center;
@@ -903,9 +903,15 @@ const skewedTowards = computed(() => {
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
overflow: hidden;
/* Long option lists scroll in place so the axes / pick / footer stay
* visible; the column-header row below stays pinned via sticky. */
max-height: 40vh;
overflow-y: auto;
}
.pool__opt-head {
position: sticky;
top: 0;
z-index: 1;
display: grid;
/* Match OptionRow column widths so cell alignment stays in lockstep. */
grid-template-columns: 22px 1fr 110px 64px 96px;
+1
View File
@@ -182,6 +182,7 @@ interface WarningEntry {
const WARNING_LABELS: Record<string, string> = {
constraint_never_applied: "Constraint never applied",
constraint_partial_reach: "Constraint partial reach",
constraint_source_missing: "Constraint source missing",
constraint_register_failed: "Constraint failed to register",
constraint_factor_ignored_on_allow: "Constraint factor ignored (allow)",
unknown_constraint_mode: "Unknown constraint mode",
+26
View File
@@ -1294,6 +1294,32 @@ describe("scanConflicts — wildcard_broken_nested_ref", () => {
});
});
it("does NOT flag when the ref resolves to a LIBRARY wildcard not in the chain", () => {
// `deadbeef` isn't in the chain, but it's a live-library wildcard — the
// Context node fills it from the DB at run (`_expand_catalog_via_live_db`),
// so the ref resolves and must NOT read as broken.
const value: ContextWidgetValue = {
version: 1,
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
};
const out = scanConflicts(value, [], [], [], [], null, new Set(["deadbeef"]));
expect(out.find((c) => c.type === "wildcard_broken_nested_ref")).toBeUndefined();
});
it("STILL flags a ref absent from BOTH the chain and the library", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
};
const out = scanConflicts(value, [], [], [], [], null, new Set(["other999"]));
expect(out).toContainEqual({
moduleId: "w1",
variable: "deadbeef",
type: "wildcard_broken_nested_ref",
severity: "warning",
});
});
it("keys the conflict to the module's _uid when present", () => {
const value: ContextWidgetValue = {
version: 1,
+13 -1
View File
@@ -451,6 +451,13 @@ export function scanConflicts(
// handed and reads `isOrphan` off that. Same reach math, narrower
// visibility.
constraintPairOrphanByUid: Map<string, boolean> | null = null,
// Uuids of every wildcard in the live library. A nested `@{uuid}` ref to a
// library wildcard that ISN'T in this chain still resolves at run time — the
// Context node expands its catalog from the live DB (`_expand_catalog_via_live_db`),
// so the ref is NOT broken. Only flag refs absent from BOTH chain + library.
// Constraint TARGET reachability deliberately stays chain-only (a constraint
// needs a downstream instance to re-weight — library presence isn't enough).
knownLibraryUuids: ReadonlySet<string> = new Set(),
): Conflict[] {
const upstream = new Set(upstreamVars);
const upstreamUuids = new Set(upstreamWildcardUuids);
@@ -618,6 +625,11 @@ export function scanConflicts(
const inDownstreamNested = downstreamNestedReach.has(uuid);
return inLocalDirect || inUpstream || inDownstream || inLocalNestedAfter || inDownstreamNested;
};
// Nested `@{uuid}` refs additionally resolve from the live library catalog
// (the engine pulls them from the DB at run time even when they're not in
// the chain), so a ref to any known library wildcard is NOT a broken ref.
const isNestedRefResolvable = (uuid: string, i: number): boolean =>
isUuidResolvable(uuid, i) || knownLibraryUuids.has(uuid);
for (let i = 0; i < value.modules.length; i++) {
const m = value.modules[i];
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
@@ -828,7 +840,7 @@ export function scanConflicts(
const uuid = match[1];
if (seenRef.has(uuid)) continue;
seenRef.add(uuid);
if (!isUuidResolvable(uuid, i)) {
if (!isNestedRefResolvable(uuid, i)) {
out.push({ moduleId: m._uid ?? m.id, variable: uuid, type, severity: "warning" });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { expandInlineChoices } from "./resolver";
describe("expandInlineChoices — branch weights (engine parity)", () => {
it("strips the N:: weight prefix — never leaks into output", () => {
for (let i = 0; i < 50; i++) {
const out = expandInlineChoices("{2::a|b}");
expect(["a", "b"]).toContain(out);
expect(out).not.toContain("::");
}
});
it("a zero-weight branch is never picked", () => {
for (let i = 0; i < 50; i++) {
expect(expandInlineChoices("{1::keep|0::drop}")).toBe("keep");
}
});
it("user repro: {0::|, long shadows} resolves to the non-empty branch", () => {
for (let i = 0; i < 50; i++) {
expect(expandInlineChoices("{0::|, long shadows}")).toBe(", long shadows");
}
});
it("unweighted choices still resolve to one branch verbatim", () => {
const out = expandInlineChoices("{a|b|c}");
expect(["a", "b", "c"]).toContain(out);
});
it("a literal foo::bar (non-numeric prefix) is left intact", () => {
// Only a NUMERIC `N::` prefix is a weight; `http::x` style stays put.
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
});
});
+41 -4
View File
@@ -49,10 +49,34 @@ export function pickWeightedOption(
return options[options.length - 1];
}
/** Parse the `N::` branch-weight prefix — 1 when absent, 0 when ≤0, 1 on a
* non-numeric prefix. Mirrors the engine's `_parse_branch_weight`. */
function parseBranchWeight(branch: string): number {
const i = branch.indexOf("::");
if (i < 0) return 1;
const prefix = branch.slice(0, i);
if (prefix.trim() === "") return 1;
const n = Number(prefix);
if (Number.isNaN(n)) return 1;
return n <= 0 ? 0 : n;
}
/** Strip a numeric `N::` weight prefix; leave the branch untouched when the
* prefix isn't a number (a literal `foo::bar` survives). Mirrors the
* engine's `_strip_branch_weight`. */
function stripBranchWeight(branch: string): string {
const i = branch.indexOf("::");
if (i < 0) return branch;
const prefix = branch.slice(0, i);
if (prefix.trim() === "" || Number.isNaN(Number(prefix))) return branch;
return branch.slice(i + 2);
}
/**
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
* resolved to one of its `|`-separated branches at random. Nested braces are
* resolved innermost-first via a fixpoint loop.
* resolved to one of its `|`-separated branches at random (weighted when a
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
* via a fixpoint loop.
*
* Important — this function MUST NOT touch `@{...}` refs. The previous
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
@@ -74,8 +98,21 @@ export function expandInlineChoices(text: string): string {
const next = out.replace(
/\{([^{}]*\|[^{}]*)\}/g,
(_, content: string) => {
const branches = content.split("|");
return branches[Math.floor(Math.random() * branches.length)];
const raw = content.split("|");
const values = raw.map(stripBranchWeight);
const hasWeights = values.some((v, i) => v !== raw[i]);
if (!hasWeights) {
return values[Math.floor(Math.random() * values.length)];
}
const weights = raw.map(parseBranchWeight);
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
if (total <= 0) return values[0];
let r = Math.random() * total;
for (let i = 0; i < values.length; i++) {
r -= Math.max(0, weights[i]);
if (r <= 0) return values[i];
}
return values[values.length - 1];
},
);
if (next === out) break;
+27
View File
@@ -388,6 +388,33 @@ def test_resolve_inline_pick_empty_branch_valid():
assert out in {"a", "", "c"}
def test_resolve_inline_pick_strips_branch_weight_prefix():
"""`{N::value}` weight prefix must never leak into the output. Regression:
simple inline picks ignored `N::`, emitting the literal `2::a`."""
for seed in range(12):
ctx = _ctx(rng=random.Random(seed))
out = resolve_text("{2::a|b}", ctx)
assert out in {"a", "b"}, f"seed {seed}: {out!r}"
assert "::" not in out, f"seed {seed}: weight prefix leaked: {out!r}"
def test_resolve_inline_pick_respects_branch_weight():
"""A zero-weight branch is never picked — weighting drives the choice."""
for seed in range(12):
ctx = _ctx(rng=random.Random(seed))
assert resolve_text("{1::keep|0::drop}", ctx) == "keep", f"seed {seed}"
def test_resolve_inline_pick_weighted_empty_branch():
"""User repro `{2::|, long shadows}`: the `2::` empty branch emits empty,
never the literal prefix. Zero-weight variant pins the assertion."""
for seed in range(12):
ctx = _ctx(rng=random.Random(seed))
assert resolve_text("{0::|, long shadows}", ctx) == ", long shadows"
out = resolve_text("{2::|, long shadows}", ctx)
assert out in {"", ", long shadows"}, f"seed {seed}: {out!r}"
def test_resolve_inline_pick_single_branch_is_text_token():
"""`{just_one}` is NOT a pick (no pipe) — tokenizer returns it as text,
so resolver passes through verbatim."""
+15
View File
@@ -603,6 +603,21 @@ class TestConstraintComposition:
]
assert excludes_all, "expected an excludes-all warning when both fire"
def test_constraint_both_ends_missing_emits_source_and_target_warnings(self):
"""A constraint whose SOURCE and TARGET wildcards are both absent from
the chain surfaces BOTH a `constraint_never_applied` (target-focused)
AND a `constraint_source_missing` warning — so the Debug viewer shows
both ends, matching the editor's separate per-end row badges."""
ctx = _run([
_wildcard("present", "color", [{"id": "c1", "value": "red", "weight": 1}]),
_constraint("cX", source="missing_src", target="missing_tgt", exceptions=[
{"source": "a", "target": "b", "mode": "exclude", "factor": 0},
]),
], seed=1)
types = [w.get("type") for w in ctx.get("__wp_warnings__", [])]
assert "constraint_never_applied" in types, types
assert "constraint_source_missing" in types, types
def test_first_mode_constraint_yields_to_let_other_survive(self):
"""The pre-SP3 'only the first constraint fires' outcome is still
reachable by marking c2 `first` and giving it no earlier target: