Files
DumiFlex-ComfyUI-Wildcard-P…/src/components/context/ContextWidget.vue
T
DumiFlex 73a00a3760 fix(context): constraint-reach QA, node codenames, bundle-aware toolbar
QA + polish on the unmerged SP3 constraint-reach work, plus the node-id
redesign and bundle-aware bulk toolbar.

- reach badge reactivity: pairingBadgeEqual compares reach + pairingMapEqual
  compares contributors (context.ts) so a target_select edit re-renders.
- TargetReachSection: neutral section highlight + custom up/down count stepper.
- nested carrier renders ONE badge (constraint-pairs.ts: nested coverage goes
  to viaInbound only, not also contributors).
- instance target_select override reaches the badge: cross-node-pairings.ts
  folds the instance value over payload; the modal collapses to null only when
  it equals the library default (preserves an explicit "all"). Engine
  precedence (instance over payload) locked by tests.
- graph.ts writeBindings honors instance.variable_binding, fixing the duplicate
  shadow ("override") badge + missing-var scan + preview for renamed bindings.
- node identity: fixed, unique, POV-independent adjective-noun codename from the
  litegraph node id (node-codename.ts); read-only neutral chip; dropped the
  editable node_label + A/B/C letters.
- nested-pick label reads "@<target> in <carrier>" (target by display name).
- bulk toolbar: toggle-all flips bundle masters + top-level modules (not bundle
  children); collapse/expand-all flips bundle frames; buttons resized to the
  row-icon size.
2026-06-11 16:38:56 +03:00

6033 lines
248 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, provide, defineAsyncComponent } from "vue";
import {
buildBundleEnabledMap,
isModuleEffectivelyEnabled,
parseWidgetJsonWithRecovery, serializeWidgetJson,
emptyContextValue, newModuleId, newRowUid,
type ContextWidgetValue, type ModuleEntry,
} from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
import { scanConflicts, labelFor as conflictLabelFor, shortConflictLabel, type Conflict } from "../../extension/conflicts";
import type { ChainModule, PairingBadge, RowPairings } from "../../extension/constraint-pairs";
import { baseCodename } from "../../extension/node-codename";
import {
getCollapseMode,
getCollapsedByDefault,
getNewModuleDisabled,
getValidationMode,
} from "../../extension/settings";
import {
ensure as ensurePreviewLookup,
lookup as previewLookup,
cacheVersion as previewCacheVersion,
} from "../../extension/preview-resolver";
import ModulePickerModal from "./ModulePickerModal.vue";
import BundlePickerModal from "./BundlePickerModal.vue";
import BundleFrame from "./bundles/BundleFrame.vue";
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
import {
cascadeRestoreForBundle,
scanCascadeRestore,
type CascadeRestoreScan,
} from "./bundles/cascade-restore";
import { reconcileBundleRanges } from "./bundles/drag";
import { resolveDropZone, type DropZone } from "./bundles/drop-zone";
import { applyDrop, type DropPayload } from "./bundles/drop";
import { buildCrossNodeBundleInsertion } from "./bundles/cross-node-nested";
import { computeBundleFingerprint, bundleSnapshotModified, isFingerprintCurrent } from "./bundles/bundle-fingerprint";
import BundleDropBar from "./bundles/BundleDropBar.vue";
import {
captureRects,
applyFlip,
withEnterAnimation,
withLeaveAnimation,
animateEnterBatch,
flashRows,
MOTION_FLIP_MS,
MOTION_CURVE_FLIP,
} from "./bundles/flip";
import { api } from "../../manager/api/client";
import { emptyBundleInstance, type BundleInstance } from "../../widgets/_shared";
import ModuleEditModal from "./ModuleEditModal.vue";
import ModalShell from "../shared/ModalShell.vue";
// Lazy: bundle edit modal pulls in its own IdentitySection +
// RuntimeSection + color-preset markup. Keeping it out of the
// ContextWidget entry chunk preserves the size budget — the modal
// only runs when the user clicks "Edit bundle…" in the ctxmenu.
const BundleInstanceModal = defineAsyncComponent(
() => import("./editors/bundle/BundleInstanceModal.vue"),
);
import PushToLibraryModal from "./PushToLibraryModal.vue";
import PushBundleToLibraryModal from "./PushBundleToLibraryModal.vue";
import ContextMenu, {
type ContextMenuEntry,
type ContextMenuHeader,
type ContextMenuItem,
} from "../shared/ContextMenu.vue";
import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./drag-store";
import { nextBindingSuffix } from "./duplicates/binding-suffix";
import { pushToast } from "../shared/toast-store";
import { kindIcon } from "../shared/kind-icons";
import { KIND_TITLE } from "./editors/_shell";
import { varColorClass } from "../shared/var-color";
import wpLogoSvg from "../shared/wp-logo.svg?raw";
import {
forceRefresh as forceRefreshHashes,
hashes as libraryHashes,
bundleHashes,
refreshMany,
refreshModule,
setLibraryHash,
subscribe as subscribeDrift,
unsubscribe as unsubscribeDrift,
} from "./drift-store";
import { classifyOne, type CollisionState } from "../../manager/import-export/collision";
import {
getBundleCollapsedByDefault,
getConfirmDestructiveBundle,
} from "../../extension/settings";
import ConfirmDialog from "../shared/ConfirmDialog.vue";
const props = withDefaults(defineProps<{
nodeId: number;
initialJson: string;
upstreamVars: string[];
/** Resolved upstream-var snapshot — `$name → resolved string` map.
* Drives the combine modal's live-preview pane so users see the
* template with vars substituted (e.g. `red portrait` instead of
* `$style portrait`) at edit time. Optional for headless mounts. */
upstreamResolved?: Record<string, ResolvedValue>;
/** Per-module resolved reader — given a moduleId, returns the map of
* vars visible from that module's perspective (upstream chain +
* earlier siblings, excluding the module itself). Modal uses this
* for the combine live preview because static `upstreamResolved`
* alone misses sibling bindings produced in the same node. */
localResolvedReader?: (moduleId?: string) => Record<string, ResolvedValue>;
/** Wildcard module uuids reachable upstream of this node. Used by
* the constraint-ordering scanner to validate constraint
* source/target references — sources in the upstream chain are
* fine (already picked), but a target in the upstream chain is a
* bug (target ran before constraint loaded). Optional for
* headless / test mounts that don't simulate a graph. */
upstreamWildcardUuids?: string[];
/** Wildcard module uuids reachable downstream of this node. Mirror
* of `upstreamWildcardUuids` — lets the scanner mark
* target-in-downstream as the GOOD case (target picks after this
* constraint runs) and fire `constraint_source_in_downstream`
* when the source is in the wrong direction. */
downstreamWildcardUuids?: string[];
/** Uuids referenced via `@{uuid}` nested refs in any downstream
* Context wildcard's option values. Mirrors the pair-badge logic
* for via-nested carriers: a constraint whose target only resolves
* through a downstream carrier still pairs (`↪×N`), so the scanner
* must agree by suppressing the `constraint_orphan_target` /
* `constraint_target_missing` warning for that route. */
downstreamNestedReachUuids?: string[];
/** Cross-node pairing badge map. Computed at the mount layer
* (`widgets/context.ts`) by walking upstream + own + downstream
* WP_Context modules into a flat chain. Keys are
* `${nodeId}#${_uid}` so duplicate library instances + cross-node
* rows don't collide. ModuleRow.vue looks up its own row's badge
* via `pairingFor(module._uid)` in `moduleRowCtx`. Optional for
* headless mounts that don't simulate a graph. */
pairings?: Map<string, RowPairings>;
/** Flattened cross-node module chain (upstream + own + downstream
* WP_Context nodes), computed at the mount layer alongside
* `pairings`. Forwarded to ModuleEditModal → ConstraintInstanceModal
* so the constraint modal can resolve a source/target wildcard that
* lives in another Context node — same flatten the pair badges use.
* Optional for headless mounts. */
chainModules?: ChainModule[];
/** Litegraph node mode: 0 ALWAYS, 2 NEVER (mute), 4 BYPASS.
* Used to dim the body when the host node is muted/bypassed so
* the runtime-skipped state is visually obvious. Other modes are
* unused for WP_Context but accepted as default-active (run). */
nodeMode?: number;
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
lastUsedSeedReader?: (moduleId?: string) => number | null;
onChange: (json: string) => void;
/** Called whenever the formula-computed minimum node width changes
* (a conflict appears, a state badge gets attached). Mount glue
* updates its tracked dynamicMinWidth + invokes host.requestRelayout
* so litegraph re-reads computeLayoutSize. Loop-free: the watch
* deps are pure Vue reactive state, never DOM measurements. */
onRequestMinWidth?: (w: number) => void;
}>(), {
nodeMode: 0,
upstreamWildcardUuids: () => [],
downstreamWildcardUuids: () => [],
downstreamNestedReachUuids: () => [],
pairings: () => new Map<string, RowPairings>(),
});
const isMuted = computed(() => props.nodeMode === 2 || props.nodeMode === 4);
const muteLabel = computed(() => props.nodeMode === 4 ? "bypassed" : "muted");
const dragOver = ref<DropZone>(null);
// `_uid` of the module currently being dragged (source row). Drives the
// lifted-ghost class so the source visually picks up alongside the
// browser's default drag image.
const draggingModuleUid = ref<string | null>(null);
// `_uid` of the most recently dropped row — pulses for 450ms after a
// drop lands, then clears.
// Phase B.4: Set + order map for multi-child pulse on cross-node bundle
// drops. Each landed uid gets a CSS animation-delay = index * 60ms via
// pulseDelayFor; the .wp-drop-pulse keyframe runs at MOTION_PULSE_MS
// (420ms) so the last row's animation finishes at
// 420 + (n-1)*60ms after the drop. Timer clears the Set + Map together.
const recentDropUids = ref<Set<string>>(new Set());
const pulseOrder = ref<Map<string, number>>(new Map());
let dropPulseTimer: number | null = null;
// Disables `.wp-list-move` + `.wp-list-leave-active` transitions while
// active so children + overlay vanish on the same tick as the frame
// (and rows below snap up instead of sliding for 250ms FLIP).
const suppressMove = ref(false);
let suppressMoveTimer: number | null = null;
// Drag anchor: the row or bundle whose CSS class opens the gap for the
// indicator bar. Computed once per dragOver change so rowGap +
// bundleGap + dropBarFor all consult the same source of truth.
//
// Slot semantics:
// - `insertIdx === <item start_idx>` → anchor at THAT item with pos
// "before" (bar above)
// - No matching item → anchor at the LAST item in the container with
// pos "after" (bar below)
//
// Empty container → no anchor (bar paints at container center via the
// dropBarFor fallback).
type AnchorInfo =
| { kind: "row"; idx: number; pos: "before" | "after"; containerUid: string | null }
| { kind: "bundle"; uid: string; pos: "before" | "after"; containerUid: string | null };
function containerItems(
containerUid: string | null,
val: ContextWidgetValue,
): Array<{ kind: "mod"; idx: number } | { kind: "bundle"; bundle: BundleInstance }> {
const bundles = val.bundles ?? [];
const out: Array<{ kind: "mod"; idx: number } | { kind: "bundle"; bundle: BundleInstance }> = [];
if (containerUid === null) {
let i = 0;
while (i < val.modules.length) {
const b = bundles.find((bb) => bb.start_idx === i && !bb.parent_uid && bb.end_idx >= bb.start_idx);
if (b) {
out.push({ kind: "bundle", bundle: b });
i = b.end_idx + 1;
} else {
out.push({ kind: "mod", idx: i });
i++;
}
}
return out;
}
const parent = bundles.find((b) => b._uid === containerUid);
if (!parent || parent.end_idx < parent.start_idx) return out;
let j = parent.start_idx;
while (j <= parent.end_idx) {
const inner = bundles.find(
(b) => b.parent_uid === containerUid && b.start_idx === j && b.end_idx >= b.start_idx,
);
if (inner) {
out.push({ kind: "bundle", bundle: inner });
j = inner.end_idx + 1;
} else {
out.push({ kind: "mod", idx: j });
j++;
}
}
return out;
}
const dragAnchor = computed<AnchorInfo | null>(() => {
const z = dragOver.value;
if (!z) return null;
const items = containerItems(z.containerUid, value.value);
if (items.length === 0) return null;
for (const it of items) {
const startIdx = it.kind === "mod" ? it.idx : it.bundle.start_idx;
if (startIdx === z.insertIdx) {
return it.kind === "mod"
? { kind: "row", idx: it.idx, pos: "before", containerUid: z.containerUid }
: { kind: "bundle", uid: it.bundle._uid, pos: "before", containerUid: z.containerUid };
}
}
const last = items[items.length - 1];
return last.kind === "mod"
? { kind: "row", idx: last.idx, pos: "after", containerUid: z.containerUid }
: { kind: "bundle", uid: last.bundle._uid, pos: "after", containerUid: z.containerUid };
});
// True when the resolved slot anchors at modules.length in the top-level
// scope — drives the sticky "Drop here" footer's active state.
const isDropEndZone = computed<boolean>(() => {
const z = dragOver.value;
if (!z) return false;
return z.containerUid === null && z.insertIdx >= value.value.modules.length;
});
function rowGap(idx: number): "before" | "after" | null {
const a = dragAnchor.value;
if (!a || a.kind !== "row" || a.idx !== idx) return null;
return a.pos;
}
// Bundle gap (frame margin opens above/below the bundle). Reads the
// drag anchor; returns "before" when this bundle is the anchor with
// pos "before", "after" when it's the anchor with pos "after".
function bundleHeaderGap(uid: string): "before" | "after" | null {
const a = dragAnchor.value;
if (!a || a.kind !== "bundle" || a.uid !== uid) return null;
return a.pos;
}
// True when the slot zone's container is THIS bundle's body — i.e. the
// drop will land somewhere inside it. Drives the frame-level highlight.
// Fires for in-bundle reorder AND for crossing into the bundle from
// outside; both cases are "drop will land in this container", so the
// affordance is consistent.
function isBundleDropTarget(uid: string): boolean {
const z = dragOver.value;
if (!z) return false;
return z.containerUid === uid;
}
/** Looks up the indicator-bar position for a drop container.
*
* `containerUid = null` → top-level list (the `.wp-modules` container
* inside the `.wp-modules-frame` ref).
* `containerUid = bundleUid` → that bundle's `.wp-bundle-children`
* scrolling body.
*
* Returns `{top}` in pixels relative to that container's content box
* when the resolved zone targets the container; null otherwise. The
* BundleDropBar component renders an absolutely-positioned line at the
* returned offset.
*
* The bar paints in the MIDDLE of the gap opened by the `wp-gap-*`
* margin (14px) — half that offset (7px) lifts the bar off the row's
* edge so it sits in the gap visibly rather than overlapping the
* row's own top/bottom border. That gives ONE clear line per drop
* position instead of "is that the bar or the row's border?".
*/
const GAP_BAR_OFFSET = 7;
function dropBarFor(containerUid: string | null): { top: number } | null {
const a = dragAnchor.value;
if (!a) {
// Empty container case (only fires for empty top-level — bundles
// always have ≥1 child by invariant). Paint at container center.
const z = dragOver.value;
if (!z || (z.containerUid ?? null) !== (containerUid ?? null)) return null;
const containerEl = findContainerEl(containerUid);
if (!containerEl) return null;
return { top: containerEl.clientHeight / 2 };
}
if ((a.containerUid ?? null) !== (containerUid ?? null)) return null;
const containerEl = findContainerEl(containerUid);
if (!containerEl) return null;
// CRITICAL: ComfyUI applies `transform: scale(zoom)` to the dom-widget
// container. `getBoundingClientRect` returns VIEWPORT pixels (scaled).
// `offsetTop` is in CSS pixels relative to offsetParent — that's the
// right unit for `position:absolute; top` under a transformed ancestor.
const anchorEl: HTMLElement | null =
a.kind === "row" ? findRowEl(a.idx, containerEl) : findBundleFrameEl(a.uid, containerEl);
if (!anchorEl) return null;
const off = elementOffsetTopWithin(anchorEl, containerEl);
return {
top: a.pos === "before"
? off - GAP_BAR_OFFSET
: off + anchorEl.offsetHeight + GAP_BAR_OFFSET,
};
}
/** Walks `offsetParent` chain from `el` up until reaching `containerEl`,
* accumulating CSS-pixel offsets. Returns `el`'s top relative to
* `containerEl`'s padding edge (i.e. the same unit `position:absolute;
* top: <value>` resolves against when `containerEl` is positioned).
* Falls back to `el.offsetTop` if `containerEl` isn't reachable via
* the offsetParent chain (defensive — would indicate broken layout). */
function elementOffsetTopWithin(el: HTMLElement, containerEl: HTMLElement): number {
let off = 0;
let cur: HTMLElement | null = el;
let safety = 0;
while (cur && cur !== containerEl && safety < 32) {
off += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
safety += 1;
}
if (cur !== containerEl) return el.offsetTop;
return off;
}
/** Locate the offset container for a given scope. Top-level → the
* `.wp-modules` div inside modulesContainer.value. Nested → that
* bundle's `.wp-bundle-children`. Returns null when the container
* isn't mounted yet (e.g. early reactive read). */
function findContainerEl(containerUid: string | null): HTMLElement | null {
const frame = modulesContainer.value;
if (!frame) return null;
if (containerUid === null) {
return frame.querySelector<HTMLElement>(":scope > .wp-modules");
}
return frame.querySelector<HTMLElement>(
`.wp-bundle[data-bundle-uid="${containerUid}"] > .wp-bundle-children`,
);
}
/** Find a module row by its absolute module idx, restricted to a given
* container scope (so nested rows don't get matched at top level). */
function findRowEl(moduleIdx: number, containerEl: HTMLElement): HTMLElement | null {
return containerEl.querySelector<HTMLElement>(
`:scope > .wp-module[data-module-idx="${moduleIdx}"]`,
) ?? containerEl.querySelector<HTMLElement>(
`.wp-module[data-module-idx="${moduleIdx}"]`,
);
}
/** Find a bundle's outer wrapper element. Used for header.before/after
* anchoring; `:scope >` keeps us in the container's immediate scope
* so nested bundles don't bleed into a parent container's lookup. */
function findBundleFrameEl(uid: string, containerEl: HTMLElement): HTMLElement | null {
return containerEl.querySelector<HTMLElement>(
`:scope > .wp-bundle[data-bundle-uid="${uid}"]`,
) ?? containerEl.querySelector<HTMLElement>(
`.wp-bundle[data-bundle-uid="${uid}"]`,
);
}
const ctxMenu = ref<{
visible: boolean;
x: number;
y: number;
items: ContextMenuEntry[];
header?: ContextMenuHeader;
}>({
visible: false,
x: 0,
y: 0,
items: [],
});
// Phase B: track the currently-edited module by INDEX, not id —
// sibling rows share `m.id`, so a id-keyed editing state would open
// (and save into) the FIRST sibling regardless of which one the user
// double-clicked. Indexing into `value.modules` directly disambiguates.
const editingIdx = ref<number | null>(null);
/** Bundle edit modal — analogous to `editingIdx` but keyed on the
* per-Context `_uid` of the bundle instance (bundles don't sit in
* `value.modules`, they have their own list). `bundleDraft` is the
* deep-cloned snapshot the user edits — cancel discards it, save
* writes back into `value.bundles[i]`. `bundleLibraryDefaults`
* caches the freshly-fetched library entry's name/color so the
* IdentitySection can show per-field "reset to library default"
* buttons. Null while the fetch is in flight or when the library
* entry is missing (deleted upstream). */
const editingBundleUid = ref<string | null>(null);
const bundleDraft = ref<BundleInstance | null>(null);
const bundleLibraryDefaults = ref<{ name: string; color: string | null } | null>(null);
/** Live BundleInstance the modal is editing — resolved from
* `editingBundleUid` against the latest `value.bundles` list so
* cascading toggles fired during the modal session reflect into
* the master `lockState` / `internalState` props immediately. */
const editingBundleEntry = computed<BundleInstance | null>(() => {
const uid = editingBundleUid.value;
if (!uid) return null;
return (value.value.bundles ?? []).find((b) => b._uid === uid) ?? null;
});
/** Right-click "Push to library…" can fire on any row without first
* opening the edit modal. The modal holds a snapshot copy so the user
* can rename/retag without dirtying the workflow row. */
const pushDraft = ref<ModuleEntry | null>(null);
const pushOpen = ref<boolean>(false);
function openPushToLibrary(idx: number): void {
const m = value.value.modules[idx];
if (!m) return;
// Deep-clone so meta edits inside the modal stay local until commit.
pushDraft.value = JSON.parse(JSON.stringify(m));
pushOpen.value = true;
}
interface PushSaveResult {
mode: "update" | "fork" | "reattach";
id: string;
payload_hash: string;
bundles_updated: string[];
name: string;
origId: string;
}
function onPushSaved(result: PushSaveResult): void {
// Refresh library hashes regardless of mode — the drift store sees
// the new entry for forks + the new hash for updates so the
// missing/drift dots clear immediately instead of waiting for the
// next poll tick.
setLibraryHash(result.id, result.payload_hash);
void forceRefreshHashes();
// Update mode: sync every workflow row with this uuid so each row's
// payload_hash + meta.library_name match the freshly-saved library
// entry. Without this, the per-field reset target on Display name
// still points at the OLD library name → reset icon shows even
// though the row IS at the library default now.
if (result.mode === "update") {
const next = value.value.modules.map((m) => {
if (m.id !== result.id) return m;
return {
...m,
payload_hash: result.payload_hash,
meta: { ...(m.meta ?? {}), library_name: result.name },
};
});
commitModules(next);
} else if (result.mode === "reattach") {
// Re-attach: source library entry was deleted upstream and the
// user saved as new. Rebind every workflow row pointing at the
// dead uuid to the freshly-created one so MISSING clears + future
// refresh/drift checks resolve against the new entry.
const next = value.value.modules.map((m) => {
if (m.id !== result.origId) return m;
return {
...m,
id: result.id,
payload_hash: result.payload_hash,
meta: { ...(m.meta ?? {}), library_name: result.name },
};
});
commitModules(next);
}
const bundlesNote =
result.bundles_updated.length > 0
? ` · ${result.bundles_updated.length} bundle${result.bundles_updated.length === 1 ? "" : "s"} synced`
: "";
let msg: string;
if (result.mode === "fork") msg = `Saved as new library entry "${result.name}"`;
else if (result.mode === "reattach") msg = `Re-attached "${result.name}" to library`;
else msg = `Saved "${result.name}" to library${bundlesNote}`;
pushToast(msg, { severity: "success" });
pushOpen.value = false;
pushDraft.value = null;
}
function onPushClosed(): void {
pushOpen.value = false;
pushDraft.value = null;
}
/* ─────── Bundle-scoped Push-to-library modal ─────── */
const pushBundleDraft = ref<BundleInstance | null>(null);
const pushBundleChildrenForLibrary = ref<Array<Record<string, unknown>>>([]);
const pushBundleChildrenPreview = ref<Array<{ name: string; kind: string }>>([]);
const pushBundleOpen = ref<boolean>(false);
const pushBundleCascadeScan = ref<CascadeRestoreScan | null>(null);
/** Captured target uid for the open modal — cascade restore reads
* this to locate the same outer BundleInstance via the workflow
* state at restore time (not modal-open time, since some prior
* cascade phase may have mutated `value.value.modules`/bundles). */
const pushBundleTargetUid = ref<string | null>(null);
function openPushBundleToLibrary(uid: string): void {
const bundles = value.value.bundles ?? [];
const target = bundles.find((b) => b._uid === uid);
if (!target) return;
const integrity = buildLibraryChildrenWithIntegrity(
target,
value.value.modules,
bundles,
);
if (integrity.orphanedInnerUids.length > 0) {
pushToast(
`Bundle has ${integrity.orphanedInnerUids.length} orphan inner reference(s) — they'll be flattened on save. Check nesting after.`,
{ severity: "warning", lifeMs: 7000 },
);
}
// Children preview — render module names + kinds, and inner-bundle
// references as "bundle: name". Inner bundles in childrenForLibrary
// are shaped `{ kind: "bundle_ref", bundle_id }` (or similar); for
// display we walk the workflow bundles to find their friendly name.
const preview: Array<{ name: string; kind: string }> = [];
for (let i = target.start_idx; i <= target.end_idx; i++) {
const m = value.value.modules[i];
if (!m) continue;
if ((m as ModuleEntry & { bundle_origin?: string }).bundle_origin !== target._uid) continue;
preview.push({ kind: m.type, name: m.meta?.name || m.type });
}
for (const b of bundles) {
if (b.parent_uid === target._uid) {
preview.push({ kind: "bundle", name: b.name || "bundle" });
}
}
pushBundleChildrenForLibrary.value = integrity.children;
pushBundleChildrenPreview.value = preview;
// Pre-scan for cascade: how many missing modules + inner bundles are
// in this outer's scope. Counts > 0 unlock the cascade UI in the
// modal; the same `cascadeRestoreForBundle` will re-walk at restore
// time with the live workflow state.
pushBundleCascadeScan.value = scanCascadeRestore({
outer: target,
modules: value.value.modules,
bundles,
isModuleMissing: isMissingFromLibrary,
isBundleMissing: isBundleMissingFromLibrary,
});
pushBundleTargetUid.value = uid;
// Clone the BundleInstance so any edits inside the modal don't dirty
// the workflow row until commit.
pushBundleDraft.value = JSON.parse(JSON.stringify(target));
pushBundleOpen.value = true;
}
/** Modal-invoked cascade pre-pass. Runs bottom-up restoration over
* the LIVE workflow state for the captured target uid, commits the
* restored uuids back to `value.value`, and returns the rewritten
* outer children for the modal's POST/PUT body. */
async function pushBundleCascadeRestore(): Promise<{
rewrittenChildren: Record<string, unknown>[];
restoredModuleCount: number;
restoredBundleCount: number;
}> {
const uid = pushBundleTargetUid.value;
const bundles = value.value.bundles ?? [];
const target = uid ? bundles.find((b) => b._uid === uid) : null;
if (!target) {
throw new Error("Cascade restore: target bundle no longer in workflow");
}
const result = await cascadeRestoreForBundle({
outer: target,
modules: value.value.modules,
bundles,
isModuleMissing: isMissingFromLibrary,
isBundleMissing: isBundleMissingFromLibrary,
});
// Commit the rebound workflow modules + bundles so the canvas stops
// rendering MISSING badges for restored items. Done in-place via
// `value.value =` so the deep-watch fires onChange.
value.value = {
...value.value,
modules: result.newModules,
bundles: result.newBundles,
};
// Refresh polling so the drift-store map picks up the freshly-POSTed
// uuids on the next render rather than waiting 5s.
void forceRefreshHashes();
return {
rewrittenChildren: result.rewrittenChildren,
restoredModuleCount: result.restoredModuleCount,
restoredBundleCount: result.restoredBundleCount,
};
}
interface BundlePushSaveResult {
mode: "update" | "fork" | "reattach";
id: string;
payload_hash: string;
name: string;
origId: string;
cascade?: { restoredModuleCount: number; restoredBundleCount: number };
}
function onBundlePushSaved(result: BundlePushSaveResult): void {
void forceRefreshHashes();
if (result.mode === "update" || result.mode === "reattach") {
// Update: refresh inserted_at_hash + snapshot fingerprint for every
// BundleInstance pointing at this library entry — drift + MOD dots
// clear instantly. Reattach: also rebind library_id from the dead
// uuid to the freshly-created one.
const nextBundles = (value.value.bundles ?? []).map((b) => {
if (b.library_id !== result.origId) return b;
const rebound: BundleInstance = {
...b,
library_id: result.id,
inserted_at_hash: result.payload_hash,
name: result.name,
snapshot_fingerprint: computeBundleFingerprint(b, value.value.modules),
};
return rebound;
});
value.value = { ...value.value, bundles: nextBundles };
}
let msg: string;
if (result.mode === "fork") msg = `Saved as new library bundle "${result.name}"`;
else if (result.mode === "reattach") msg = `Re-attached "${result.name}" to library`;
else msg = `Saved "${result.name}" to library`;
if (result.cascade) {
const parts: string[] = [];
if (result.cascade.restoredModuleCount > 0) {
parts.push(`${result.cascade.restoredModuleCount} module${result.cascade.restoredModuleCount === 1 ? "" : "s"}`);
}
if (result.cascade.restoredBundleCount > 0) {
parts.push(`${result.cascade.restoredBundleCount} inner bundle${result.cascade.restoredBundleCount === 1 ? "" : "s"}`);
}
if (parts.length > 0) msg += ` · cascade-restored ${parts.join(" + ")}`;
}
pushToast(msg, { severity: "success" });
pushBundleOpen.value = false;
pushBundleDraft.value = null;
pushBundleTargetUid.value = null;
pushBundleCascadeScan.value = null;
}
function onBundlePushClosed(): void {
pushBundleOpen.value = false;
pushBundleDraft.value = null;
pushBundleTargetUid.value = null;
pushBundleCascadeScan.value = null;
}
const editingModule = computed<ModuleEntry | null>(() =>
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
);
/** Per-option pair lookup for the currently-edited wildcard. Map keys
* are option ids; values are pair badges whose `via.optionIds` include
* that option. Drives the trailing `↪#N` chip inside the wildcard
* modal's options table. Empty for non-wildcard modules and for
* wildcards that aren't a constraint carrier. */
const editingModuleViaOptionPairs = computed<Map<string, PairingBadge[]>>(() => {
const m = editingModule.value;
if (!m) return new Map();
const carrierPairs = viaInboundFor(m._uid ?? m.id);
if (carrierPairs.length === 0) return new Map();
const out = new Map<string, PairingBadge[]>();
for (const p of carrierPairs) {
const optionIds = p.via?.optionIds ?? [];
for (const optId of optionIds) {
const arr = out.get(optId);
if (arr) arr.push(p);
else out.set(optId, [p]);
}
}
return out;
});
/** Live-preview source of truth for the modal: combines upstream-chain
* bindings + sibling bindings produced in this same node (minus the
* module being edited). Falls back to the static `upstreamResolved`
* prop when the per-module reader isn't wired (legacy mounts /
* headless tests). Recomputed on every editingIdx change so the
* preview pane reflects the right perspective. */
const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
const m = editingModule.value;
if (props.localResolvedReader && m) {
return props.localResolvedReader(m.id);
}
return props.upstreamResolved ?? {};
});
// Variable names defined in OTHER modules of this same node — used by the
// edit modal's autocomplete + per-entry validity. Exclude the module being
// edited so its own (in-flight) names don't echo back as suggestions.
//
// Each kind contributes via its own producer field:
// - fixed_values → entries[].variable_name (UI mirror) + payload.values[].name
// and instance.values_overrides[].name
// - wildcard → instance.variable_binding ?? payload.var_binding
// - combine → instance.variable_binding ?? payload.output_var
// - derivation → payload.rules[].branches[].action.target_var
// and payload.rules[].else.action.target_var
// Combine + wildcard have no entries; without this kind-aware fallback the
// modal's insert-var dropdown would render empty for chains where the only
// vars come from binding-producer modules.
const siblingNodeVars = computed<string[]>(() => {
const names = new Set<string>();
function add(name: string | undefined | null): void {
const trimmed = (name ?? "").replace(/^\$+/, "").trim();
if (trimmed) names.add(trimmed);
}
const editingM = editingModule.value;
const bundleEnabled = buildBundleEnabledMap(value.value.bundles);
for (let mi = 0; mi < value.value.modules.length; mi++) {
const m = value.value.modules[mi];
if (mi === editingIdx.value) continue;
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
// Defensive: also skip if same object (shouldn't happen but Vue
// proxies can confuse identity equals).
void editingM;
for (const e of m.entries) add(e.variable_name);
const inst = (m.instance ?? {}) as {
variable_binding?: string | null;
values_overrides?: Array<{ name?: string }>;
};
const p = (m.payload ?? {}) as {
var_binding?: string;
output_var?: string;
values?: Array<{ name?: string }>;
rules?: Array<{
branches?: Array<{ action?: { target_var?: string } }>;
else?: { action?: { target_var?: string } };
}>;
};
if (m.type === "wildcard") {
add(inst.variable_binding ?? p.var_binding);
} else if (m.type === "combine") {
add(inst.variable_binding ?? p.output_var);
} else if (m.type === "fixed_values") {
const overrides = inst.values_overrides;
if (Array.isArray(overrides) && overrides.length > 0) {
for (const v of overrides) add(v.name);
} else {
for (const v of p.values ?? []) add(v.name);
}
} else if (m.type === "derivation") {
for (const rule of p.rules ?? []) {
for (const br of rule.branches ?? []) add(br.action?.target_var);
add(rule.else?.action?.target_var);
}
}
}
return [...names];
});
function clearDragHover() {
if (dragOver.value === null) return;
dragOver.value = null;
}
onMounted(() => {
window.addEventListener("dragend", clearDragHover);
// Subscribe to the shared drift-store — it manages the 5s poll of the
// /wp/api/modules/hashes endpoint and exposes the live-hash map that
// drives both the missing-dot (this task) and the drift-dot (Task 3).
subscribeDrift();
});
onBeforeUnmount(() => {
window.removeEventListener("dragend", clearDragHover);
if (dragState.value?.sourceNodeId === props.nodeId) dragState.value = null;
if (suppressMoveTimer != null) {
window.clearTimeout(suppressMoveTimer);
suppressMoveTimer = null;
}
if (dropPulseTimer != null) {
window.clearTimeout(dropPulseTimer);
dropPulseTimer = null;
}
unsubscribeDrift();
});
watch(dragState, (v) => { if (v === null) clearDragHover(); });
// Backup cleanup path for cross-node handoffs. Primary path: the
// source row's `dragend` listener reads `dragState.consumedBy` and
// splices the row out. That fires reliably under most conditions
// but has been observed to miss when the row's containing bundle
// frame re-renders during the same drag tick (constraint duplicates
// inside the "final framing" bundle were the trigger). When the
// target widget queues a handoff in its drop handler, this watch
// fires on the source widget and removes the row by `_uid` —
// idempotent with the dragend path because filtering a uid that's
// already gone is a no-op.
//
// Idempotency note: both branches re-read the current bundle uid /
// range from the live state rather than the (possibly stale) values
// captured at drop time. The dragend handler runs the same lookup
// for the same reason — it's the only way to avoid double-removal
// when Vue's watch flush schedules this watcher between the target
// queue write and the browser-dispatched dragend on the source.
watch(pendingHandoffs, () => {
const mine = takeHandoffsFor(props.nodeId);
if (mine.length === 0) return;
let modules = value.value.modules;
let bundles = value.value.bundles ?? [];
let changed = false;
for (const h of mine) {
if (h.kind === "module" && h.uid) {
const before = modules.length;
modules = modules.filter((m) => m._uid !== h.uid);
if (modules.length !== before) changed = true;
} else if (h.kind === "bundle" && h.bundleUid) {
// Look up the bundle by uid in the LIVE state — sourceStartIdx
// / sourceEndIdx captured at drop time may be stale if a sibling
// op has shifted indices, or if dragend already ran.
const live = bundles.find((b) => b._uid === h.bundleUid);
if (!live) continue;
const start = live.start_idx;
const end = live.end_idx;
if (start < 0 || end < start || end >= modules.length) continue;
const movingBundleUids = new Set<string>([h.bundleUid]);
let grew = true;
while (grew) {
grew = false;
for (const b of bundles) {
if (b.parent_uid && movingBundleUids.has(b.parent_uid) && !movingBundleUids.has(b._uid)) {
movingBundleUids.add(b._uid);
grew = true;
}
}
}
const removedCount = end - start + 1;
modules = [...modules.slice(0, start), ...modules.slice(end + 1)];
bundles = bundles
.filter((b) => !movingBundleUids.has(b._uid))
.map((b) => (b.start_idx > end
? { ...b, start_idx: b.start_idx - removedCount, end_idx: b.end_idx - removedCount }
: b));
changed = true;
}
}
if (changed) commitModules(modules, bundles);
});
// Initial parse runs through the recovery path so we can flag bad workflow
// JSON instead of silently swallowing it. parseWidgetJson stays exported for
// the debug/assembler widgets which don't need recovery semantics.
const initialParse = parseWidgetJsonWithRecovery(props.initialJson, emptyContextValue());
ensureRowUids(initialParse.value.modules);
ensureBundleFingerprints(initialParse.value.bundles ?? [], initialParse.value.modules);
const value = ref<ContextWidgetValue>(initialParse.value);
/**
* Atomic write to `value.value` whenever the modules array changes
* shape (insert, remove, reorder) AND for in-place updates that don't
* shift indices. Always pipes through `reconcileBundleRanges` so the
* bundles[] cache (start_idx / end_idx per BundleInstance) stays in
* lock-step with each child's `bundle_origin` field — the canonical
* source of truth for bundle membership.
*
* - `nextModules` is the post-mutation array. Pass it as a fresh
* reference (already spread / mapped / sliced); the helper does not
* clone it.
* - `bundlesOverride` is for callers that have hand-computed the
* post-mutation bundles[] (e.g. removeBundle adjusting sibling
* indices, picker insertion appending a fresh BundleInstance). The
* override is the INPUT to reconcile, not the final write — the
* helper will still groom the result so a hand-computed bundle that
* no longer has any children with matching `bundle_origin` gets
* dropped, and a contiguous run that the override missed gets its
* start/end recomputed.
*
* Reconcile is idempotent, so calling this helper at sites that
* didn't change indices (instance toggle, modal save, bulk collapse)
* is a no-op on the bundles[] side and free from the perspective of
* correctness. Routing every mutation through the helper means
* "every modules write also writes bundles" is enforced by the call
* site shape, not by author memory.
*/
function commitModules(
nextModules: ModuleEntry[],
bundlesOverride?: BundleInstance[],
): void {
const sourceBundles = bundlesOverride ?? value.value.bundles ?? [];
value.value = {
...value.value,
modules: nextModules,
bundles: reconcileBundleRanges(nextModules, sourceBundles),
};
}
/** Stamp `_uid` on any module missing one. Phase B: each row needs a
* per-instance stable Vue v-for key that survives reorders + inserts —
* siblings share `m.id` (library uuid), so id alone isn't unique, and
* composite `${id}|${idx}` would change for every row when the array
* shifts (breaks TransitionGroup move animations). Backfill in-place
* on initial load + after re-parse so existing workflow JSON works. */
function ensureRowUids(modules: ModuleEntry[]): boolean {
let mutated = false;
for (const m of modules) {
if (!m._uid) {
m._uid = newRowUid();
mutated = true;
}
}
return mutated;
}
/** Stamp `snapshot_fingerprint` on any BundleInstance missing one OR
* carrying a stale-version baseline. Backfill-on-load: workflows
* saved before MOD detection landed get a false-clean baseline
* computed from current state; bundles whose stored fingerprint
* predates a format bump (see `FINGERPRINT_VERSION` in
* `bundle-fingerprint.ts`) are silently rebaselined here so they
* don't render with a permanent "modified" badge after the upgrade.
* Subsequent edits flip the bundle to modified correctly. */
function ensureBundleFingerprints(
bundles: BundleInstance[],
modules: ModuleEntry[],
): boolean {
let mutated = false;
for (const b of bundles) {
if (!isFingerprintCurrent(b.snapshot_fingerprint)) {
b.snapshot_fingerprint = computeBundleFingerprint(b, modules);
mutated = true;
}
}
return mutated;
}
/** Re-snap fingerprints for the given bundle uids using the current
* modules + bundles state. Returns a new bundles array with updated
* fingerprints. Call sites: post-insert (sync local state with library
* snapshot), post-save (record what we just published), post-reset
* (record the library state we just pulled), post-cross-node (record
* the freshly-cloned snapshot). */
function snapBundleFingerprints(
bundles: BundleInstance[],
modules: ModuleEntry[],
uidsToSnap: Set<string>,
): BundleInstance[] {
return bundles.map((b) =>
uidsToSnap.has(b._uid)
? { ...b, snapshot_fingerprint: computeBundleFingerprint(b, modules) }
: b,
);
}
const parseError = ref<string | null>(initialParse.error);
const parseRaw = ref<string>(initialParse.raw);
const showRaw = ref(false);
const showPicker = ref(false);
// 4.5 — animated arrow on first run. Persists dismissal across reloads via
// localStorage so the hint never re-appears once the user has clicked into
// the picker.
const FIRST_RUN_KEY = "wp:hint:first-add";
const firstRunHintDismissed = ref<boolean>(
typeof window !== "undefined" && window.localStorage?.getItem(FIRST_RUN_KEY) === "dismissed",
);
function dismissFirstRunHint() {
if (firstRunHintDismissed.value) return;
firstRunHintDismissed.value = true;
// Wrapped — Safari private mode + locked-down iframes can throw on writes.
try { window.localStorage?.setItem(FIRST_RUN_KEY, "dismissed"); } catch { /* ignore */ }
}
function openPicker() {
dismissFirstRunHint();
showPicker.value = true;
}
// Bundle picker state — second add-flow alongside the module picker.
// Opened via the "+ Add Bundle" button in the footer + empty-state.
const showBundlePicker = ref(false);
function openBundlePicker() {
dismissFirstRunHint();
showBundlePicker.value = true;
}
/** Fetch a bundle from the library, deserialize its children via the
* insert glue, splice into modules[] at the end of the current list,
* and push a fresh BundleInstance into bundles[]. Mutating the shared
* `value` ref triggers the standard write-back + assembler refresh
* path. */
async function onPickBundle(bundleId: string): Promise<void> {
try {
const entry = await api.bundles.get(bundleId);
const libEntry: BundleLibraryEntry = {
id: entry.id,
name: entry.name,
color: entry.color,
children: entry.children as BundleLibraryEntry["children"],
payload_hash: entry.payload_hash,
};
const insertIdx = value.value.modules.length;
const { modulesToSplice, bundleInstance, innerInstances } = buildBundleInsertion(libEntry, insertIdx);
// Honor "Collapse new bundles by default". Default false keeps
// bundles open on insert (existing behaviour); when true, stamp
// the collapsed flag so the frame mounts header-only. Inner
// (nested) bundles inherit the same default so the user sees a
// consistent collapse state across the whole insert.
if (getBundleCollapsedByDefault()) {
bundleInstance.collapsed = true;
for (const inner of innerInstances) inner.collapsed = true;
}
// Mutate via spread to keep the ref's identity stable + trigger
// Vue reactivity. modules[] gets the new rows at the tail, bundles[]
// gets the new instance.
//
// `modulesToSplice` carries the loose ChildSnapshot shape from
// uuid-remap (only `id` + `type` typed). Bundle library children
// are stored as FULL module snapshots at save time — defaults are
// backfilled here for any fields the bundle editor didn't write
// explicitly so the spliced row satisfies ModuleEntry at runtime.
const splice = modulesToSplice.map((c) => {
const rec = c as Record<string, unknown>;
const meta = (rec.meta as ModuleEntry["meta"] | undefined) ?? { name: "" };
const entries = (rec.entries as ModuleEntry["entries"] | undefined) ?? [];
return {
id: c.id,
_uid: c._uid,
type: c.type as ModuleEntry["type"],
enabled: (rec.enabled as boolean | undefined) ?? true,
// Bundle children inserted in their collapsed state per
// `buildBundleInsertion` — the explicit rebuild above
// dropped this field; now carried through so the chevron
// starts in collapsed position.
collapsed: (rec.collapsed as boolean | undefined) ?? false,
meta,
entries,
payload: c.payload,
instance: c.instance,
payload_hash: c.payload_hash,
bundle_origin: c.bundle_origin,
} as ModuleEntry;
});
commitModules(
[...value.value.modules, ...splice],
[...(value.value.bundles ?? []), bundleInstance, ...innerInstances],
);
// Snap fingerprints on the freshly-inserted bundles so the local
// state is recorded as the "clean baseline" — subsequent user
// edits flip them to modified, post-commit so reconcileBundleRanges
// has already set the correct start_idx/end_idx for the walk.
{
const justInserted = new Set<string>([
bundleInstance._uid,
...innerInstances.map((b) => b._uid),
]);
value.value = {
...value.value,
bundles: snapBundleFingerprints(value.value.bundles ?? [], value.value.modules, justInserted),
};
}
// Phase B.6: animate the new bundle wrapper + its children with
// the same fade-slide as picker-add. Bundle wrapper carries the
// same --arriving/--arrived classes thanks to the .wp-bundle CSS
// selectors. UIDs: bundle._uid + each child._uid.
await nextTick();
if (modulesContainer.value) {
await animateEnterBatch(
[bundleInstance._uid, ...splice.map(c => c._uid)],
modulesContainer.value,
);
}
// Delta-undo capture: every _uid we added (modules + outer + inners).
// Undo filters them out of whatever modules/bundles arrays look like
// at click time. Composes correctly with any other op's Undo
// regardless of click order — full-snapshot restore would overwrite
// a sibling op's state. Without capturing inner uids, undoing a
// nested-bundle insert orphaned the inner BundleInstances (the outer
// was removed but its inners stayed, dangling).
const insertedModuleUids = new Set(splice.map((c) => c._uid).filter((u): u is string => !!u));
const insertedBundleUids = new Set<string>([
bundleInstance._uid,
...innerInstances.map((b) => b._uid),
]);
pushToast(`Inserted bundle "${entry.name}"`, {
severity: "success",
lifeMs: 5000,
action: {
label: "Undo",
onSelect: () => {
const list = value.value.modules.filter(
(m) => !m._uid || !insertedModuleUids.has(m._uid),
);
const curBundles = value.value.bundles ?? [];
const nextBundlesArr = curBundles.filter(
(b) => !insertedBundleUids.has(b._uid),
);
commitModules(list, nextBundlesArr);
},
},
});
} catch (e) {
// Surface fetch / parse errors via the existing toast channel so
// users see what went wrong without diving into devtools.
const msg = e instanceof Error ? e.message : String(e);
pushToast(`Bundle insert failed: ${msg}`, { severity: "error" });
}
}
/** Stub — opens SPA in author-mode to create a new bundle. v1 routes
* to /bundles/new; SPA wires up the editor in Phase 4. */
function openBundleAuthor(): void {
openSpaLibrary(); // Reuse existing SPA link for v1 — Phase 4 deep-links to bundle editor
}
function toggleBundleCollapsed(uid: string): void {
const bundles = value.value.bundles ?? [];
const idx = bundles.findIndex((b) => b._uid === uid);
if (idx < 0) return;
const updated = { ...bundles[idx], collapsed: !bundles[idx].collapsed };
const next = [...bundles];
next[idx] = updated;
value.value = { ...value.value, bundles: next };
}
function toggleBundleEnabled(uid: string, enabled: boolean): void {
const bundles = value.value.bundles ?? [];
const bIdx = bundles.findIndex((b) => b._uid === uid);
if (bIdx < 0) return;
const updated = { ...bundles[bIdx], enabled };
const nextBundles = [...bundles];
nextBundles[bIdx] = updated;
// Bundle.enabled is a gate, not a mutator. Children keep their own
// instance.enabled state untouched — re-enabling the bundle restores
// every child to its previous individual on/off. The engine sees the
// gated view via `deserialize_node_input` (wp_nodes/types.py), which
// ANDs bundle.enabled with each child's enabled at execute time. The
// renderer dims the disabled bundle's children via CSS at the bundle
// level (.wp-bundle--disabled overlay), no per-row class change.
commitModules(value.value.modules, nextBundles);
}
// Container ref for list-level drag handlers + bar positioning.
const modulesContainer = ref<HTMLElement | null>(null);
// Suppresses wp-list-move + wp-list-leave-active transitions for the
// duration; covers the longest TransitionGroup transition (250ms FLIP)
// plus a margin so leaving rows + FLIP-moving siblings all snap.
function holdSuppressMove(): void {
suppressMove.value = true;
if (suppressMoveTimer != null) window.clearTimeout(suppressMoveTimer);
suppressMoveTimer = window.setTimeout(() => {
suppressMove.value = false;
suppressMoveTimer = null;
}, 300);
}
/**
* Capture FLIP rects at every list-level container in the widget.
* Returns a snapshot to feed back into `playFlipSnapshot` after Vue
* commits the mutated `value.value` and re-renders.
*
* Two scope levels are captured non-overlappingly:
* - `.wp-modules` direct children: bundles + standalone modules
* - each `.wp-bundle-children` direct children: in-bundle modules
*
* Capturing both lets us animate top-level reorders AND in-bundle
* reorders cleanly. A bundle moving as a unit only FLIPs at the top
* level; its children get the transform via inheritance and skip the
* inner pass (their rects don't change relative to the bundle).
*/
type FlipSnapshot = { scope: HTMLElement; before: Map<string, DOMRect> }[];
function captureFlipSnapshot(): FlipSnapshot {
const frame = modulesContainer.value;
if (!frame) return [];
const snapshot: FlipSnapshot = [];
const top = frame.querySelector<HTMLElement>(".wp-modules");
if (top) snapshot.push({ scope: top, before: captureRects(top, el => el.dataset.uid ?? null) });
for (const inner of Array.from(frame.querySelectorAll<HTMLElement>(".wp-bundle-children"))) {
snapshot.push({ scope: inner, before: captureRects(inner, el => el.dataset.uid ?? null) });
}
return snapshot;
}
/**
* Drops a uid from every scope's pre-mutation rect map so playFlipSnapshot
* leaves that element alone. Used when the row should animate via a
* different mechanism (e.g. --arriving fade-slide for cross-scope moves)
* — without this, the FLIP inline transform would override the class-
* driven transition.
*/
function excludeFromFlipSnapshot(snapshot: FlipSnapshot, uid: string): void {
for (const entry of snapshot) entry.before.delete(uid);
}
function playFlipSnapshot(snapshot: FlipSnapshot): void {
for (const { scope, before } of snapshot) {
applyFlip(scope, before, el => el.dataset.uid ?? null, {
duration: MOTION_FLIP_MS,
ease: MOTION_CURVE_FLIP,
});
}
}
async function removeBundle(uid: string): Promise<void> {
const bundles = value.value.bundles ?? [];
const target = bundles.find((b) => b._uid === uid);
if (!target) return;
const bundleName = target.name || "bundle";
if (!(await maybeConfirm({
title: `Remove "${bundleName}"?`,
body: `Removes the bundle frame and all ${target.end_idx - target.start_idx + 1} child(ren) from this Context. Toast Undo will restore them if clicked before it expires.`,
variant: "danger",
confirmLabel: "Remove",
}))) return;
// Delta-undo capture: keep the actual child refs (with bundle_origin
// intact) so re-insertion restores complete module shape, plus an
// anchor `_uid` for the module that NOW sits immediately after the
// bundle. Splicing relative to that anchor on undo lands the
// restored children near their original position even if a sibling
// op moved things around between remove and Undo. Full-snapshot
// restore breaks composition: undoing two removes out of order
// would leave only the last-clicked snapshot's effect intact.
const removedChildren = value.value.modules.slice(target.start_idx, target.end_idx + 1);
const anchorAfter = value.value.modules[target.end_idx + 1]?._uid ?? null;
const restoredBundle = target;
// Capture nested BundleInstances under this outer so undo restores
// them too. Without this, Undo brings back the leaves + outer frame
// but the inner frames vanish permanently, surfacing every
// inner-leaf as a direct child of the outer instead.
const restoredInners = bundles.filter((b) => b.parent_uid === uid);
// Suppress legacy wp-list-move + leave-active CSS immediately so any
// downstream layout shifts during the fade don't double-animate via
// the old TransitionGroup leftover rule. (#8 regression test asserts
// data-suppress-move="true" lands synchronously after the call.)
holdSuppressMove();
// Phase B.6: fade the bundle wrapper out via --leaving before the
// splice. Bundle CSS responds to --leaving so the whole frame slides
// + fades together. Wait MOTION_FADE_MS, then mutate.
const scope = modulesContainer.value;
if (scope) {
await withLeaveAnimation(uid, scope, () => {});
}
const flipSnap = captureFlipSnapshot();
// Drop all child modules in [start_idx..end_idx] from the flat
// modules array AND drop the BundleInstance itself.
const before = value.value.modules.slice(0, target.start_idx);
const after = value.value.modules.slice(target.end_idx + 1);
const removedCount = target.end_idx - target.start_idx + 1;
// Walk parent_uid descendants of the targeted bundle — every nested
// bundle inside this frame goes with it. Without this drop, the inner
// BundleInstance survives the removal with a parent_uid pointing at a
// deleted bundle, the renderer never picks it up as top-level (because
// it still has parent_uid), and the leaves it pointed at are gone
// anyway. Net result: orphaned BundleInstance + ghost in bundles[].
const removedUids = new Set<string>([uid]);
// BFS over parent_uid pointers — tier-2 cap → at most one hop, but
// walk generically in case the model gains depth later.
let frontier = [uid];
for (let safety = 0; safety < 8 && frontier.length > 0; safety++) {
const next: string[] = [];
for (const f of frontier) {
for (const b of bundles) {
if (b.parent_uid === f && !removedUids.has(b._uid)) {
removedUids.add(b._uid);
next.push(b._uid);
}
}
}
frontier = next;
}
// Adjust any sibling bundle indices that sit after the removed range.
const remainingBundles = bundles
.filter((b) => !removedUids.has(b._uid))
.map((b) => {
if (b.start_idx > target.end_idx) {
return {
...b,
start_idx: b.start_idx - removedCount,
end_idx: b.end_idx - removedCount,
};
}
return b;
});
commitModules([...before, ...after], remainingBundles);
await nextTick();
playFlipSnapshot(flipSnap);
pushToast(`Removed bundle "${bundleName}"`, {
severity: "info",
lifeMs: 6000,
action: {
label: "Undo",
onSelect: () => {
// Find the anchor in current state. If it moved or vanished,
// fall back to end-of-list. Splice children + restore the
// BundleInstance; reconcile pins the new range via the
// children's bundle_origin field.
const current = value.value.modules;
const anchorIdx = anchorAfter
? current.findIndex((m) => m._uid === anchorAfter)
: -1;
const insertAt = anchorIdx >= 0 ? anchorIdx : current.length;
const list = [
...current.slice(0, insertAt),
...removedChildren,
...current.slice(insertAt),
];
const curBundles = value.value.bundles ?? [];
// Restore the outer + every inner BundleInstance the remove
// dropped along with it. Skips entries that already exist
// (e.g. user undid a sibling op that recreated the inner).
const present = new Set(curBundles.map((b) => b._uid));
const toRestore = [restoredBundle, ...restoredInners].filter(
(b) => !present.has(b._uid),
);
const nextBundlesArr = toRestore.length > 0
? [...curBundles, ...toRestore]
: curBundles;
commitModules(list, nextBundlesArr);
},
},
});
}
/** Detach bundle frame — keep children as standalone modules but
* drop the bundle instance + clear `bundle_origin` on each child.
* Useful when user wants to keep what's inside but no longer wants
* the group wrapping. */
async function detachBundle(uid: string): Promise<void> {
const bundles = value.value.bundles ?? [];
const target = bundles.find((b) => b._uid === uid);
if (!target) return;
const bundleName = target.name || "bundle";
// Phase B.6 polish: fade the bundle wrapper out via --leaving before
// the splice. Symmetry with removeBundle — wrapper disappears with a
// visual exit, children stay (they will rise via FLIP into the gap).
const scope = modulesContainer.value;
if (scope) {
await withLeaveAnimation(uid, scope, () => {});
}
const flipSnap = captureFlipSnapshot();
// Nested-bundle handling: inner BundleInstances (parent_uid ===
// target._uid) survive the detach. They're promoted to top-level
// (parent_uid → null) so the inner frame stays intact and grouped —
// the user only asked to remove THIS bundle's wrapper, not flatten
// the whole tree.
const innerBundles = bundles.filter((b) => b.parent_uid === target._uid);
const innerUids = new Set(innerBundles.map((b) => b._uid));
// When detaching an INNER bundle (one with a parent_uid), its
// direct children should remain inside the OUTER bundle, not
// escape to top-level. We re-tag their `bundle_origin` to the
// outer's `_uid` so the outer frame keeps owning them. When the
// target is itself top-level (parent_uid null/absent), the
// children simply lose their origin tag as before.
const parentUid = target.parent_uid ?? null;
// Track which children we cleared `bundle_origin` on. Keyed by
// per-instance _uid so the Undo path can find the same rows even
// if they've moved since detach — full-snapshot restore breaks the
// moment a sibling op lands between detach and Undo (snapshot
// reflects sibling's pre-state too and the user loses the sibling
// op's effect). Delta-undo only re-stamps these specific children
// so each detach's Undo composes regardless of click order.
const detachedChildUids = new Set<string>();
const nextModules = value.value.modules.map((m, idx) => {
if (idx < target.start_idx || idx > target.end_idx) return m;
const origin = (m as ModuleEntry & { bundle_origin?: string }).bundle_origin;
// Inner-bundle children of THIS bundle keep their `bundle_origin`
// so the inner frame still owns them after this bundle detaches.
if (origin && innerUids.has(origin)) return m;
if (m._uid) detachedChildUids.add(m._uid);
const next = { ...m } as ModuleEntry & { bundle_origin?: string };
if (parentUid) {
// Re-tag to the surviving outer bundle so children stay
// grouped inside it. Detaching the inner just dissolves
// the inner frame — outer still wraps everything.
next.bundle_origin = parentUid;
} else {
// Top-level detach: children become free-floating siblings.
delete next.bundle_origin;
}
return next;
});
const remainingBundles = bundles
.filter((b) => b._uid !== uid)
.map((b) => (innerUids.has(b._uid) ? { ...b, parent_uid: null } : b));
// Capture the BundleInstance as it was BEFORE the strip so Undo can
// reinsert the original library_id, color, name, collapsed flag,
// inserted_at_hash, etc. without re-fetching from the library API.
const detachedBundle = target;
commitModules(nextModules, remainingBundles);
await nextTick();
playFlipSnapshot(flipSnap);
pushToast(`Detached bundle "${bundleName}"`, {
severity: "info",
lifeMs: 5000,
action: {
label: "Undo",
onSelect: () => {
// Re-stamp bundle_origin on the children currently matching
// the tracked _uids — operates on whatever state the widget is
// in now, NOT on a snapshot. If some children have been moved
// apart since detach, reconcile will see them as non-contiguous
// and dissolve the bundle naturally. That's a degradation, not
// a corruption — sibling ops keep their effects.
const list = value.value.modules.map((m) => {
if (!m._uid || !detachedChildUids.has(m._uid)) return m;
return { ...m, bundle_origin: detachedBundle._uid } as ModuleEntry & { bundle_origin?: string };
});
const currentBundles = value.value.bundles ?? [];
// Re-insert the BundleInstance only if it isn't already back
// (idempotent — guards against double-click Undo) AND re-pin
// any inner bundles' parent_uid back to this outer's _uid.
// The detach promoted them to top-level (parent_uid → null);
// Undo restores the nesting.
const restoredBundles = currentBundles.some((b) => b._uid === detachedBundle._uid)
? currentBundles
: [...currentBundles, detachedBundle];
const nextBundlesArr = restoredBundles.map((b) =>
innerUids.has(b._uid) ? { ...b, parent_uid: detachedBundle._uid } : b,
);
commitModules(list, nextBundlesArr);
},
},
});
}
/** True duplicate — clones the bundle's CURRENT in-Context state
* (including every local edit on children, the per-instance name
* / color overrides, the collapsed flag, and any inner bundles)
* and splices the clone immediately after the original.
*
* Previously this delegated to `onPickBundle(target.library_id)`
* which re-fetched the LIBRARY snapshot — useful as an "insert
* again" affordance but not as a "duplicate". User-reported: if
* you'd renamed, recolored, locked seeds, marked rows internal,
* etc., none of that survived through the duplicate. The
* semantically correct "duplicate" is `deepClone(target +
* range)` with fresh `_uid`s + an updated bundle_origin /
* parent_uid mapping.
*
* Fresh `_uid`s for every cloned row and bundle so the clone
* doesn't share identity with the original — drag, MOD, undo,
* cascade-state aggregation all key off `_uid`.
*/
async function duplicateBundle(uid: string): Promise<void> {
const bundles = value.value.bundles ?? [];
const target = bundles.find((b) => b._uid === uid);
if (!target) return;
const modules = value.value.modules;
const flipSnap = captureFlipSnapshot();
// Slice the current child range (deep clone for safety — the
// engine + drift store both hold references to module objects).
const sliceClone = modules
.slice(target.start_idx, target.end_idx + 1)
.map((m) => JSON.parse(JSON.stringify(m)) as ModuleEntry & { bundle_origin?: string });
// Build uid maps: every outer + inner BundleInstance in scope
// gets a fresh `_uid`; the row clones rewrite `bundle_origin`
// through the map so the clone's row → bundle membership mirrors
// the original. Inner BundleInstances also rewrite `parent_uid`
// through the map.
const newOuterUid = newRowUid();
const innerBundles = bundles.filter((b) => b.parent_uid === target._uid);
const uidMap = new Map<string, string>();
uidMap.set(target._uid, newOuterUid);
for (const inner of innerBundles) uidMap.set(inner._uid, newRowUid());
// Update each cloned row's bundle_origin + give it a fresh _uid.
const insertStart = target.end_idx + 1;
const clonedRows = sliceClone.map((m) => {
const origin = m.bundle_origin;
return {
...m,
_uid: newRowUid(),
bundle_origin: origin && uidMap.has(origin) ? uidMap.get(origin)! : newOuterUid,
} as ModuleEntry & { bundle_origin?: string };
});
// Splice the clone immediately after the original; shift every
// later bundle's index range by the clone length.
const cloneLen = clonedRows.length;
const before = modules.slice(0, insertStart);
const after = modules.slice(insertStart);
const nextModules = [...before, ...clonedRows, ...after];
// Preserve the source's nesting: when the user duplicates an INNER
// bundle, the clone must land alongside the original (same parent).
// Hardcoding null here detached the clone, and worse the parent's
// range never got extended below — outer bundle's end_idx stopped
// at the original inner's end, so the cloned rows fell OUT of the
// outer's range and the outer's bookkeeping diverged from reality.
const targetParentUid = target.parent_uid ?? null;
// Build cloned BundleInstance for outer + each inner.
const clonedOuter: BundleInstance = {
...target,
_uid: newOuterUid,
start_idx: insertStart,
end_idx: insertStart + cloneLen - 1,
parent_uid: targetParentUid,
// Snapshot fingerprint deliberately UNSET — the clone may
// diverge from the original immediately. `ensureBundleFingerprints`
// backfills the clean baseline on the next commit so a fresh
// clone reads "not modified".
snapshot_fingerprint: undefined,
};
const clonedInners: BundleInstance[] = innerBundles.map((inner) => {
const newUid = uidMap.get(inner._uid)!;
// Inner index range shifts by the same `+insertStart - target.start_idx`
// offset every row in the clone did.
const shift = insertStart - target.start_idx;
return {
...inner,
_uid: newUid,
parent_uid: newOuterUid,
start_idx: inner.start_idx + shift,
end_idx: inner.end_idx + shift,
snapshot_fingerprint: undefined,
};
});
// Three-rule index shift for existing bundles:
// 1. The duplicated bundle's PARENT (if any) extends end_idx by
// cloneLen so the cloned sibling stays inside its range — the
// standard "shift if end_idx >= insertStart" rule doesn't catch
// this when the target is the parent's last child (parent.end_idx
// == insertStart - 1 in that case).
// 2. Any other bundle starting at or after insertStart shifts both
// start_idx and end_idx by cloneLen (it's entirely after the
// insertion).
// 3. A bundle straddling the insertion (start_idx < insertStart <=
// end_idx) shifts only end_idx — the cloned rows landed inside it.
const nextBundles = bundles.map((b) => {
if (targetParentUid && b._uid === targetParentUid) {
return { ...b, end_idx: b.end_idx + cloneLen };
}
if (b.start_idx >= insertStart) {
return { ...b, start_idx: b.start_idx + cloneLen, end_idx: b.end_idx + cloneLen };
}
if (b.end_idx >= insertStart) {
return { ...b, end_idx: b.end_idx + cloneLen };
}
return b;
});
commitModules(nextModules, [...nextBundles, clonedOuter, ...clonedInners]);
await nextTick();
// Sibling rows shift via FLIP, the cloned outer (and any inner
// clones) slide-in via animateEnterBatch. Matches wrapIntoNewBundle's
// motion grammar so all bundle-shape mutations read the same.
playFlipSnapshot(flipSnap);
if (modulesContainer.value) {
const enterUids = [newOuterUid, ...clonedInners.map((b) => b._uid)];
void animateEnterBatch(enterUids, modulesContainer.value);
}
pushToast(`Duplicated "${target.name || "bundle"}" with local edits`, {
severity: "success",
lifeMs: 4000,
});
}
/** Reset bundle to library snapshot — re-fetch the library entry
* + replace current children with the frozen blob. Drops any user
* edits made to bundle children since insert. Confirms first since
* it's destructive. */
async function resetBundleToLibrary(uid: string): Promise<void> {
const bundles = value.value.bundles ?? [];
const target = bundles.find((b) => b._uid === uid);
if (!target) return;
// Delta-undo capture: keep the old child refs (their _uids will be
// discarded by the reset since the new library snapshot stamps
// fresh ones, but the BundleInstance _uid we re-find by below
// stays stable). The bundle's `_uid` is the join key — Undo finds
// the BundleInstance currently bearing it (whatever its range is
// now) and replaces the live child range with our captured slice.
// Full-snapshot restore would overwrite any sibling op's state on
// click; delta-undo composes.
const restoredBundleUid = target._uid;
const oldChildren = value.value.modules.slice(target.start_idx, target.end_idx + 1);
if (!(await maybeConfirm({
title: `Reset "${target.name || "bundle"}" to library?`,
body: "Drops every local edit on this bundle's children and replaces them with the library snapshot. Toast Undo restores your edits if clicked in time.",
variant: "danger",
confirmLabel: "Reset",
}))) return;
// Themed confirm above replaces the legacy `window.confirm` path —
// ComfyUI hosts can suppress the native dialog AND it looked out of
// place against the WP styling. The op is recoverable via Undo, but
// the confirm gates accidental resets on bundles with significant
// local work.
try {
const entry = await api.bundles.get(target.library_id);
const libEntry: BundleLibraryEntry = {
id: entry.id,
name: entry.name,
color: entry.color,
children: entry.children as BundleLibraryEntry["children"],
payload_hash: entry.payload_hash,
};
// Replace the current bundle's child range with the freshly
// deserialized library snapshot. Preserve the BundleInstance
// _uid + position so the overlay doesn't visually re-appear
// elsewhere; we just replace its content.
const startIdx = target.start_idx;
const replacementInsertion = buildBundleInsertion(libEntry, startIdx);
// buildBundleInsertion mints a brand-new outer + brand-new inners.
// For reset, we discard the new outer's identity (re-use `target._uid`
// so the frame stays put) but KEEP each fresh inner's identity
// (they're genuinely new bundles even from the user's perspective —
// the old inners' state is being overwritten).
//
// Identifying outer-leaves vs inner-leaves in modulesToSplice: every
// outer-leaf was stamped with `freshOuterUid`; every inner-leaf was
// stamped with one of the `innerInstances[k]._uid`s. So a child
// whose `bundle_origin === freshOuterUid` is an outer-leaf and
// needs remapping to `target._uid`; everything else is an
// inner-leaf whose origin already points at the fresh inner it
// belongs to.
const freshOuterUid = replacementInsertion.bundleInstance._uid;
// Preserve existing child `_uid`s where the position carries over
// — buildBundleInsertion mints fresh uids by default, but reset
// semantically replaces the SAME row with its library snapshot.
// Keeping uids stable matters for nested bundles: the outer
// bundle's fingerprint walks every leaf uid in its index range,
// and a uid churn on reset would flip the outer to "modified"
// even when the reset was the only outstanding edit. Fresh uids
// only get minted for positions the new snapshot adds beyond the
// old child count (i.e. the library snapshot grew since insert).
const newChildren = replacementInsertion.modulesToSplice.map((c, i) => {
const rec = c as Record<string, unknown>;
const meta = (rec.meta as ModuleEntry["meta"] | undefined) ?? { name: "" };
const entries = (rec.entries as ModuleEntry["entries"] | undefined) ?? [];
const origin = c.bundle_origin === freshOuterUid ? target._uid : c.bundle_origin;
const preservedUid = oldChildren[i]?._uid ?? c._uid;
return {
id: c.id,
_uid: preservedUid,
type: c.type as ModuleEntry["type"],
enabled: (rec.enabled as boolean | undefined) ?? true,
collapsed: (rec.collapsed as boolean | undefined) ?? false,
meta,
entries,
payload: c.payload,
instance: c.instance,
payload_hash: c.payload_hash,
bundle_origin: origin,
} as ModuleEntry;
});
// Inner BundleInstances: rewrite their parent_uid from the freshly
// minted outer to the existing outer so the nested frames sit
// inside the preserved bundle wrapper.
const newInnerInstances = replacementInsertion.innerInstances.map((b) => ({
...b,
parent_uid: target._uid,
}));
const before = value.value.modules.slice(0, startIdx);
const after = value.value.modules.slice(target.end_idx + 1);
const sizeDelta = newChildren.length - (target.end_idx - target.start_idx + 1);
// Update bundle range to match new children length + shift
// later bundles by the delta.
const nextBundles = bundles
// Drop the OLD inner bundles attached to this outer — the new
// insertion replaces them wholesale with fresh inner instances.
.filter((b) => b.parent_uid !== target._uid)
.map((b) => {
if (b._uid === uid) {
return {
...b,
end_idx: startIdx + newChildren.length - 1,
inserted_at_hash: entry.payload_hash,
};
}
if (b.start_idx > target.end_idx) {
return {
...b,
start_idx: b.start_idx + sizeDelta,
end_idx: b.end_idx + sizeDelta,
};
}
return b;
});
commitModules([...before, ...newChildren, ...after], [...nextBundles, ...newInnerInstances]);
// Re-snap fingerprints for the outer + every fresh inner so the
// reset registers as the new clean baseline. Without this, MOD
// would stay true after reset (current state != stale fingerprint
// from previous insert).
//
// NOTE: the PARENT bundle (when this one is nested) is deliberately
// NOT re-snapped here. Resetting a single inner bundle shouldn't
// baseline the outer's MOD state — if siblings of the inner are
// still locally edited, the outer correctly stays modified.
// Instead, child `_uid`s are preserved across the reset below
// (oldChildren[i]._uid reused for newChildren[i]) so the outer's
// existing fingerprint can be re-evaluated against the new state
// and clear itself iff the inner reset was the only outstanding
// edit.
{
const justReset = new Set<string>([
target._uid,
...newInnerInstances.map((b) => b._uid),
]);
value.value = {
...value.value,
bundles: snapBundleFingerprints(value.value.bundles ?? [], value.value.modules, justReset),
};
}
await nextTick();
// Flash every fresh child + the bundle wrapper so the user sees
// which rows just got replaced with the library snapshot.
if (modulesContainer.value) {
void flashRows(
[target._uid, ...newChildren.map(c => c._uid)],
modulesContainer.value,
);
}
pushToast(`Reset "${entry.name}" to library`, {
severity: "success",
lifeMs: 6000,
action: {
label: "Undo",
onSelect: () => {
// Find the BundleInstance in current state (may have moved
// since reset). If it's gone, do nothing — sibling ops kept
// their effects, but the bundle frame can't be restored
// without it.
const liveBundles = value.value.bundles ?? [];
const live = liveBundles.find((b) => b._uid === restoredBundleUid);
if (!live) return;
const liveModules = value.value.modules;
const list = [
...liveModules.slice(0, live.start_idx),
...oldChildren,
...liveModules.slice(live.end_idx + 1),
];
commitModules(list, liveBundles);
},
},
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
pushToast(`Reset failed: ${msg}`, { severity: "error" });
}
}
/** Reset a single bundle child to its library-snapshot version.
* Looks up the parent BundleInstance via the child's `bundle_origin`,
* fetches the bundle library entry, picks the snapshot at the same
* position-within-bundle, and replaces just THIS child's payload +
* instance. Other children untouched. */
async function resetChildToBundleSnapshot(idx: number): Promise<void> {
const m = value.value.modules[idx];
if (!m) return;
const originUid = (m as ModuleEntry & { bundle_origin?: string }).bundle_origin;
if (!originUid) return;
const bundles = value.value.bundles ?? [];
const bundle = bundles.find((b) => b._uid === originUid);
if (!bundle) return;
const posInBundle = idx - bundle.start_idx;
if (posInBundle < 0) return;
// Delta-undo capture: the original module ref keeps its `_uid` so
// Undo can find it in live state regardless of position. Reset
// only swaps payload/instance/payload_hash; the _uid stays put,
// so re-applying the captured ref by uid is a clean inverse.
const originalModule = m;
const targetUid = m._uid;
const childName = m.meta?.name?.trim() || m.type;
try {
const entry = await api.bundles.get(bundle.library_id);
const snapshot = entry.children[posInBundle] as Record<string, unknown> | undefined;
if (!snapshot) {
pushToast(`No snapshot at position ${posInBundle}`, { severity: "error" });
return;
}
const nextModules = value.value.modules.map((existing, i) => {
if (i !== idx) return existing;
return {
...existing,
payload: snapshot.payload as Record<string, unknown> | undefined,
instance: snapshot.instance as ModuleEntry["instance"],
payload_hash: snapshot.payload_hash as string | undefined,
} as ModuleEntry;
});
commitModules(nextModules);
pushToast(`Reset "${childName}" to bundle snapshot`, {
severity: "success",
lifeMs: 5000,
action: {
label: "Undo",
onSelect: () => {
if (!targetUid) return;
const list = value.value.modules.map((live) =>
live._uid === targetUid ? originalModule : live,
);
commitModules(list);
},
},
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
pushToast(`Reset failed: ${msg}`, { severity: "error" });
}
}
/** Wrap THIS module into a new library bundle (single-row v1).
* Users can drag more modules into the range afterwards — the
* range-integrity logic handles extension automatically.
*
* Bundle name auto-derived from the wrapped module's display name
* (falls back to "New Bundle"). ComfyUI's frontend host suppresses
* `window.prompt` in some environments — replacing it with a default
* + post-rename via the SPA editor keeps the wrap atomic and never
* silently aborts. */
async function wrapIntoNewBundle(idx: number): Promise<void> {
const m = value.value.modules[idx];
if (!m) return;
const name = m.meta?.name?.trim() || "New Bundle";
const flipSnap = captureFlipSnapshot();
try {
const entry = await api.bundles.create({
name, color: null, children: [toChildSnapshot(m)],
});
const bundleInstance: BundleInstance = {
...emptyBundleInstance(entry.id),
start_idx: idx, end_idx: idx,
inserted_at_hash: entry.payload_hash,
name: entry.name, color: entry.color ?? null,
};
const nextModules = value.value.modules.map((existing, i) =>
i === idx ? { ...existing, bundle_origin: bundleInstance._uid } as ModuleEntry : existing,
);
commitModules(
nextModules,
[...(value.value.bundles ?? []), bundleInstance],
);
// Snap fingerprint on the freshly-wrapped bundle so its MOD
// indicator starts clean (matches what we just published to lib).
value.value = {
...value.value,
bundles: snapBundleFingerprints(
value.value.bundles ?? [],
value.value.modules,
new Set([bundleInstance._uid]),
),
};
await nextTick();
// Sibling rows shift to accommodate the new bundle wrapper around
// the wrapped row. Bundle wrapper fades-slides in; the wrapped
// module flashes (green ring) to confirm 'this is what just got
// wrapped'.
playFlipSnapshot(flipSnap);
if (modulesContainer.value) {
void animateEnterBatch([bundleInstance._uid], modulesContainer.value);
void flashRows([m._uid], modulesContainer.value, 0);
}
pushToast(`Wrapped into "${entry.name}" — rename in Library editor`, { severity: "success" });
} catch (e) {
pushToast(`Wrap failed: ${e instanceof Error ? e.message : String(e)}`, { severity: "error" });
}
}
/** Bundle right-click menu. Mirrors module ctxmenu pattern — sets
* ctxMenu state with bundle-scoped items. The shared ContextMenu
* component handles positioning + dismiss. */
function openBundleContextMenu(ev: MouseEvent, uid: string): void {
const target = (value.value.bundles ?? []).find((b) => b._uid === uid);
if (!target) return;
const estW = 240;
const estH = 220;
const x = Math.min(ev.clientX, window.innerWidth - estW - 8);
const y = Math.min(ev.clientY, window.innerHeight - estH - 8);
const items: ContextMenuEntry[] = [
{
label: "Edit bundle…",
icon: "pi-pencil",
subtitle: "Display name, color, lock + hide cascades",
onSelect: () => openBundleEditModal(uid),
divider: true,
},
{
label: target.collapsed ? "Expand bundle" : "Collapse bundle",
icon: target.collapsed ? "pi-caret-down" : "pi-caret-right",
onSelect: () => toggleBundleCollapsed(uid),
},
{
label: target.enabled ? "Disable bundle" : "Enable bundle",
icon: target.enabled ? "pi-eye-slash" : "pi-eye",
subtitle: "Cascades to all children",
onSelect: () => toggleBundleEnabled(uid, !target.enabled),
divider: true,
},
{
label: "Reset to library snapshot",
icon: "pi-refresh",
subtitle: "Replace children with frozen library state",
onSelect: () => { void resetBundleToLibrary(uid); },
},
(() => {
const bundleMissing = isBundleMissingFromLibrary(target);
return {
label: "Push to library…",
icon: "pi-cloud-upload",
subtitle: bundleMissing
? "Library entry deleted — re-add to library"
: "Rename, retag, overwrite library entry",
accent: bundleMissing,
onSelect: () => openPushBundleToLibrary(uid),
divider: true,
};
})(),
{
label: "Duplicate bundle",
icon: "pi-clone",
subtitle: "Insert another instance below",
onSelect: () => { void duplicateBundle(uid); },
},
{
label: "Detach",
icon: "pi-link",
subtitle: "Frame removed, children stay",
onSelect: () => detachBundle(uid),
divider: true,
},
{
label: "Remove bundle + children",
icon: "pi-trash",
danger: true,
onSelect: () => removeBundle(uid),
},
];
ctxMenu.value = {
visible: true,
x: Math.max(8, x),
y: Math.max(8, y),
items,
header: {
icon: "pi-box",
label: `Bundle · ${target.name ?? "bundle"}`,
iconColor: target.color || undefined,
},
};
}
// Populated vs. empty page swap is driven by a wrapper <Transition
// mode="out-in"> below. mode="out-in" sequences populated→empty cleanly:
// the populated block fades out fully BEFORE the empty hero fades in, so
// the leaving card and the appearing hero never visually stack.
const isEmpty = computed(() => value.value.modules.length === 0);
function resetCorruptValue() {
// User confirmed reset — replace with empty + clear the recovery panel.
// The deep-watcher will fire onChange so the workflow JSON is rewritten.
parseError.value = null;
showRaw.value = false;
value.value = emptyContextValue();
pushToast("Workflow data reset to empty.", { severity: "info", lifeMs: 4000 });
}
function isCollapsed(m: ModuleEntry): boolean {
return m.collapsed === true;
}
let suppressWatch = false;
watch(value, (v) => {
if (suppressWatch) { suppressWatch = false; return; }
props.onChange(serializeWidgetJson(v));
}, { deep: true });
watch(() => props.initialJson, (raw) => {
// Reuse the recovery-aware parser so a workflow loaded with corrupt JSON
// surfaces the same panel as a node that was already corrupt at mount.
const next = parseWidgetJsonWithRecovery(raw, emptyContextValue());
ensureRowUids(next.value.modules);
parseError.value = next.error;
parseRaw.value = next.raw;
if (serializeWidgetJson(next.value) === serializeWidgetJson(value.value)) return;
suppressWatch = true;
value.value = next.value;
});
const conflicts = computed<Conflict[]>(() => {
// scanConflicts internally skips per-module via isModuleEffectivelyEnabled
// (handles both child.enabled=false AND bundle.enabled=false). Passing the
// full modules array preserves position semantics for constraint-target /
// nested-ref lookups that need to know where every module sits regardless
// of run state.
// Build the per-uid orphan map from the cross-node pairings prop.
// Cross-node-pairings keys are `${nodeId}#${_uid}` — strip the
// node-id prefix so the scanner can lookup by local `_uid`. Only
// constraint rows in this Context node carry meaningful pair data;
// wildcard / carrier rows have no `direct` of their own here.
const pairOrphanByUid = new Map<string, boolean>();
const nodeIdPrefix = `${props.nodeId}#`;
for (const [rowKey, rp] of props.pairings) {
if (!rp.direct) continue;
if (!rowKey.startsWith(nodeIdPrefix)) continue;
const uid = rowKey.slice(nodeIdPrefix.length);
pairOrphanByUid.set(uid, rp.direct.isOrphan);
}
const all = scanConflicts(
value.value,
props.upstreamVars,
props.upstreamWildcardUuids,
props.downstreamWildcardUuids,
props.downstreamNestedReachUuids,
pairOrphanByUid,
);
// Filter by user's validation-strictness preference. The accessor
// reads from the same module-level state map the panel onChange
// updates, so the computed picks up the new mode without manual
// wiring (computed re-evaluates whenever its reactive deps —
// value, props — change; the validation read is captured fresh
// each evaluation, which is fine here because state changes
// always coincide with a reactive trigger upstream).
const mode = getValidationMode();
if (mode === "permissive") return [];
if (mode === "relaxed") return all.filter((c) => c.severity !== "info");
return all;
});
const conflictsByModule = computed(() => {
const out: Record<string, Conflict[]> = {};
for (const c of conflicts.value) (out[c.moduleId] ??= []).push(c);
return out;
});
// Pairing badge lookup — reads from the cross-node `pairings` prop
// composed at the mount layer. Keys are `${nodeId}#${_uid}` so we have
// to prefix the local row's `_uid` with our own `nodeId` before
// lookup. Falls back to `id` for legacy rows that haven't been backfilled
// with a `_uid` yet (`ensureRowUids()` normally runs on load, but a
// barebones test mount can skip it).
function pairingFor(rowUid: string): PairingBadge | null {
const key = `${props.nodeId}#${rowUid}`;
return props.pairings?.get(key)?.direct ?? null;
}
/** Inbound via-pairings — non-empty when this row is the CARRIER of
* one or more constraints reaching their target through a nested
* `@{uuid}` ref in this wildcard's option values. UI renders these
* as a collapsed `↪×N` chip beside the row's existing direct pair
* chip (if any), with a popover listing each. */
function viaInboundFor(rowUid: string): PairingBadge[] {
const key = `${props.nodeId}#${rowUid}`;
return props.pairings?.get(key)?.viaInbound ?? [];
}
/** SP3 contributor cluster — every constraint whose reach covers this
* target-instance row, in per-target `#N` order. Reads the same
* cross-node `pairings` prop as `pairingFor` / `viaInboundFor`,
* prefixing the local `_uid` with our `nodeId`. ModuleRow renders these
* BEFORE the module name: ≤2 as individual `#N` chips, ≥3 as one
* collapsed `↥×N` chip. Empty for rows no constraint covers. */
function contributorsFor(rowUid: string): PairingBadge[] {
const key = `${props.nodeId}#${rowUid}`;
return props.pairings?.get(key)?.contributors ?? [];
}
function severityFor(id: string): "error" | "warning" | "info" | null {
const list = conflictsByModule.value[id];
if (!list?.length) return null;
if (list.some((c) => c.severity === "error")) return "error";
if (list.some((c) => c.severity === "warning")) return "warning";
return "info";
}
/**
* Short text label for the conflict badge that sits next to the
* `.wp-conflict-dot`. Picks the most-severe conflict on this
* module (error > warning > info) and maps its type to a 1-2 word
* tag so users recognise the issue without hovering for the
* tooltip — pairs with the dot the same way the status badges
* pair with the drift / missing / mod dots above. Returns `null`
* if the module has no conflicts.
*
* Wording stays short on purpose; the full sentence form lives in
* `conflictTooltip` for the title attribute. Constraint-specific
* subtypes get their own short form so a constraint row with a
* source/target wiring issue surfaces a meaningful label rather
* than the generic "conflict".
*/
function conflictBadgeText(id: string): string | null {
const list = conflictsByModule.value[id];
if (!list?.length) return null;
// Pick the highest-severity conflict; tie-break by first occurrence
// so the badge wording stays stable across renders. Label text comes
// from the shared `shortConflictLabel` so module + injector rows
// surface identical chip wording for the same conflict type.
const order = { error: 0, warning: 1, info: 2 } as const;
const top = [...list].sort(
(a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9),
)[0];
return shortConflictLabel(top.type);
}
function conflictTooltip(id: string): string {
const list = conflictsByModule.value[id];
if (!list?.length) return "";
return list.map((c) => {
// Constraint conflict types carry a wildcard uuid in `variable`,
// not a $-var name. Resolve via the same sibling/preview-resolver
// lookup constraint card labels use, so the tooltip shows
// `$hair_color` instead of the unreadable 8-hex slug. Falls back
// to the bare uuid only when the resolver can't find a name —
// matches the card label's contract.
if (c.type.startsWith("constraint_")) {
const name = lookupSiblingName(c.variable);
const display = name ?? c.variable;
return `${conflictLabelFor(c.type)}: $${display}`;
}
return `${conflictLabelFor(c.type)}: $${c.variable}`;
}).join("\n");
}
/**
* Card subtitle line. fixed_values surfaces the inline-edited
* `$var, $var, …` list (the legacy summary); other kinds inspect
* the snapshot payload to surface a one-glance signature
* (var-binding + option count for wildcards, output-var for
* combines, rule count for derivations, etc.).
*/
/**
* Per-instance override detection. A library-snapshot module is
* "modified" when any field on `m.instance` differs from the engine's
* `_fresh_instance()` defaults — option enable/disable, weight
* overrides, mode (pinned / subcategory), or category filter. Drives
* the small accent dot on the card header so users can spot which
* picked modules they've tweaked locally.
*
* Symmetric with the modal's `hasInstanceOverrides` (kept in sync
* manually — small enough surface that DRYing isn't worth a shared
* util yet).
*/
/**
* True when a module's id isn't in the live library set. Modules
* with no `payload_hash` are treated as LOCAL-ONLY by design (no
* library tie expected). This covers two cases at once:
* - inline-created `fixed_values` entries (never had a library
* row — payload_hash never set)
* - duplicates of any kind (the duplicate path strips
* payload_hash so the clone reads as local; see `duplicateModule`)
*
* Returns false until `libraryHashes` first loads so we don't flash
* "missing" everywhere while the initial fetch is in flight.
*/
/**
* Identity verdict of an embedded row against the live library — or null
* until the first poll lands / for local-only rows (no payload_hash).
* Single source for the card dots. Content key is `payload_hash`, so
* drift semantics match the historical behavior (content-only); the
* `type` gate is the new cross-kind clash detector. Shared verdict logic
* lives in `classifyOne` (import-export/collision).
*/
function matchStateOf(m: ModuleEntry): CollisionState | null {
if (!m.payload_hash) return null;
if (libraryHashes.value === null) return null;
const live = libraryHashes.value[m.id];
return classifyOne(
m.type,
m.payload_hash,
live ? { type: live.type, contentKey: live.payload_hash } : undefined,
);
}
function isMissingFromLibrary(m: ModuleEntry): boolean {
return matchStateOf(m) === "no-collision";
}
/**
* Drift = library still has this uuid (SAME kind), but the live
* `payload_hash` differs from what was embedded at pick time. Independent
* of `isModified` (user overrides). Mutually exclusive with the clash
* state below — a different-kind row is `type-conflict`, not drift.
*/
function isDrifted(m: ModuleEntry): boolean {
return matchStateOf(m) === "conflict";
}
/**
* Type clash = the live library row at this uuid is a DIFFERENT kind (the
* 8-hex id-space is shared across all 5 kinds). Refresh / Push-update
* would clobber an unrelated item, so the card offers neither — only
* "Push to library…" as a fresh entry.
*/
function isTypeConflict(m: ModuleEntry): boolean {
return matchStateOf(m) === "type-conflict";
}
/** Per-card refresh — replace one drifted entry with the live snapshot.
* Errors surface as toasts; a 404 (deleted between poll and refresh)
* is treated as missing-from-library because the next poll tick will
* flip the dot from drift → missing.
*
* Phase B (2026-05-10): takes the row's array index (siblings share
* `m.id`, so id-keyed `.map()` overwrote every sibling with one merged
* copy → all rows lost their per-instance state + `_uid`, breaking
* TransitionGroup keying).
*/
async function refreshOne(idx: number): Promise<void> {
const m = value.value.modules[idx];
if (!m) return;
// Track whether the refreshed row was inside a bundle BEFORE the
// refresh so we can extend the success toast with a "bundle has
// unsaved changes" hint. mergeRefresh preserves bundle_origin so the
// membership doesn't change, but its payload_hash will diverge from
// the bundle's snapshot fingerprint → modified=true.
const sourceBundleUid = (m as ModuleEntry & { bundle_origin?: string }).bundle_origin ?? null;
try {
const merged = await refreshModule(m);
if (value.value.modules[idx] !== m) return; // row shifted while loading
const next = [...value.value.modules];
next[idx] = merged;
// Route through commitModules so reconcileBundleRanges re-envelopes
// bundle ranges around the refreshed row. Direct assignment skipped
// reconcile, so when mergeRefresh used to strip bundle_origin (now
// fixed) the bundle silently dissolved on the next mutation.
commitModules(next);
await nextTick();
if (modulesContainer.value) {
void flashRows([merged._uid], modulesContainer.value, 0);
}
const bundleName = sourceBundleUid
? (value.value.bundles ?? []).find((b) => b._uid === sourceBundleUid)?.name ?? null
: null;
const toastBody = bundleName
? `Refreshed "${merged.meta.name || merged.type}". Bundle "${bundleName}" has unsaved changes — Save to library to update.`
: `Refreshed "${merged.meta.name || merged.type}".`;
pushToast(toastBody, { severity: "success", lifeMs: bundleName ? 7000 : undefined });
await forceRefreshHashes();
} catch (err) {
const msg = (err as Error).message ?? "unknown";
if (msg === "not in library") {
pushToast(`"${m.meta.name || m.type}" no longer in library — try Save to library.`, { severity: "warning" });
} else {
pushToast(`Refresh failed: ${msg}`, { severity: "error" });
}
}
}
/** Bulk — refresh every drifted entry in one batched fetch.
* Phase B: when N siblings share a uuid, refreshMany returns N merged
* entries in input order. Apply them BACK by index, preserving each row's
* position + its own `_uid` (mergeRefresh already pins the source row's
* instance/_uid). FIFO queue per uuid keeps the parallel-array
* invariant when merge-output skips failed uuids. */
async function refreshAllDrifted(): Promise<void> {
const driftedIdx: number[] = [];
value.value.modules.forEach((m, i) => { if (isDrifted(m)) driftedIdx.push(i); });
if (driftedIdx.length === 0) return;
const driftedRows = driftedIdx.map((i) => value.value.modules[i]);
// Collect uids of bundles that own any drifted row BEFORE the refresh
// — used to append a "bundle has unsaved changes" hint to the success
// toast. Each refresh diverges the row's payload_hash from the
// bundle's snapshot fingerprint → modified=true.
const affectedBundleUids = new Set<string>();
for (const m of driftedRows) {
const o = (m as ModuleEntry & { bundle_origin?: string }).bundle_origin;
if (o) {
affectedBundleUids.add(o);
// Also walk parent chain so an inner-leaf refresh marks the outer
// bundle modified too (it owns the inner's range).
const cur = (value.value.bundles ?? []).find((b) => b._uid === o);
const pu = cur?.parent_uid;
if (typeof pu === "string" && pu) affectedBundleUids.add(pu);
}
}
const result = await refreshMany(driftedRows);
if (result.refreshed.length > 0) {
const queueByUuid = new Map<string, ModuleEntry[]>();
for (const r of result.refreshed) {
const q = queueByUuid.get(r.id) ?? [];
q.push(r);
queueByUuid.set(r.id, q);
}
const next = [...value.value.modules];
const refreshedUids: string[] = [];
for (const i of driftedIdx) {
const m = next[i];
const merged = queueByUuid.get(m.id)?.shift();
if (merged) {
next[i] = merged;
if (merged._uid) refreshedUids.push(merged._uid);
}
}
// commitModules (vs direct assignment) re-runs reconcileBundleRanges
// so bundles[] envelopes the refreshed rows correctly. Critical when
// any refreshed row sits inside a bundle — direct assignment skipped
// reconcile and the bundle silently dissolved on the next mutation.
commitModules(next);
await nextTick();
if (modulesContainer.value && refreshedUids.length > 0) {
void flashRows(refreshedUids, modulesContainer.value);
}
await forceRefreshHashes();
}
// Resolve human-readable names for the affected bundles (post-refresh
// state, in case any vanished via reconcile).
const bundleNames = [...affectedBundleUids]
.map((uid) => (value.value.bundles ?? []).find((b) => b._uid === uid)?.name)
.filter((n): n is string => !!n);
const bundleHint = bundleNames.length === 0
? ""
: bundleNames.length === 1
? ` Bundle "${bundleNames[0]}" has unsaved changes — Save to library to update.`
: ` ${bundleNames.length} bundles have unsaved changes — Save each to library to update.`;
if (result.failed.length === 0) {
pushToast(
`Refreshed all ${result.refreshed.length}.${bundleHint}`,
{ severity: "success", lifeMs: bundleHint ? 7000 : undefined },
);
} else {
pushToast(
`Refreshed ${result.refreshed.length} of ${driftedRows.length}; ${result.failed.length} stayed drifted.${bundleHint}`,
{ severity: "warning", lifeMs: bundleHint ? 7000 : undefined },
);
}
}
/** Surfaced as a computed for the bulk-button visibility + label. */
const driftedCount = computed(() => value.value.modules.filter(isDrifted).length);
// Per-bundle drift count — sums isDrifted over the bundle's child
// range. Drives the `${count} drifted` suffix on the bundle header
// subtitle so users can glance-spot bundles whose children fell out
// of sync with their library snapshots. Read at render time so the
// libraryHashes poll naturally feeds updates without a watcher.
function bundleChildDriftCount(bundle: BundleInstance): number {
const mods = value.value.modules;
let n = 0;
for (let i = bundle.start_idx; i <= bundle.end_idx; i++) {
if (isDrifted(mods[i])) n++;
}
return n;
}
/** True when the bundle library entry has changed since this bundle
* was inserted: compare the locally captured `inserted_at_hash` with
* the freshest hash from the polled `bundleHashes` map. Returns false
* until first poll lands so the UI doesn't flash a drift state before
* the truth is known. Distinct from per-child drift — a library may
* have a new payload_hash even when every embedded child still
* matches its individual snapshot (e.g. the library author only
* reordered children, or swapped a child the user previously
* detached locally). */
function isBundleLibraryDrifted(bundle: BundleInstance): boolean {
const map = bundleHashes.value;
if (map === null) return false;
const live = map[bundle.library_id];
if (live === undefined) return false;
if (!bundle.inserted_at_hash) return false;
return live !== bundle.inserted_at_hash;
}
/** True when the bundle's library entry has been deleted upstream —
* the polled `bundleHashes` map no longer contains its uuid. Pairs
* with `isMissingFromLibrary` for modules. Returns false until first
* poll lands so we don't flash MISSING before the truth is known. */
function isBundleMissingFromLibrary(bundle: BundleInstance): boolean {
const map = bundleHashes.value;
if (map === null) return false;
if (!bundle.library_id) return false;
return !(bundle.library_id in map);
}
// Pending confirm-dialog state. Single slot — only one destructive op
// can be in flight at a time, and the bundle ctxmenu / header buttons
// are synchronous enough that overlapping calls don't happen in
// practice. `maybeConfirm` resolves through a Promise so the call site
// can await it and decide whether to proceed.
const pendingConfirm = ref<{
title: string;
body: string;
variant: "default" | "danger";
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
} | null>(null);
interface MaybeConfirmOpts {
title: string;
body: string;
variant?: "default" | "danger";
confirmLabel?: string;
cancelLabel?: string;
}
/** Show the themed ConfirmDialog if the user has the
* "Confirm destructive bundle actions" setting enabled, otherwise
* resolve immediately to `true`. Returned Promise resolves to the
* user's choice (true = proceed, false = cancel). Call sites pattern:
*
* if (!(await maybeConfirm({ title, body, variant: "danger" }))) return;
*/
function maybeConfirm(opts: MaybeConfirmOpts): Promise<boolean> {
if (!getConfirmDestructiveBundle()) return Promise.resolve(true);
return new Promise<boolean>((resolve) => {
pendingConfirm.value = {
title: opts.title,
body: opts.body,
variant: opts.variant ?? "default",
confirmLabel: opts.confirmLabel ?? "Confirm",
cancelLabel: opts.cancelLabel ?? "Cancel",
onConfirm: () => {
pendingConfirm.value = null;
resolve(true);
},
onCancel: () => {
pendingConfirm.value = null;
resolve(false);
},
};
});
}
/** Tri-state aggregation of `instance.internal` across a bundle's
* children. Drives the master-toggle visual in BundleHeader:
* - "all" → every child has internal=true (button pressed)
* - "none" → no child has internal (button neutral)
* - "partial" → mixed (button half-pressed, dashed border)
*
* Empty bundle defaults to "none" — the button is harmless to show
* but clicking it does nothing (no children to flip). */
function bundleInternalState(
bundle: BundleInstance,
): "all" | "none" | "partial" | null {
const mods = value.value.modules;
let on = 0;
let total = 0;
for (let i = bundle.start_idx; i <= bundle.end_idx; i++) {
// Skip kinds that don't support the internal flag (constraint).
// Otherwise a bundle of one constraint + N internal wildcards
// would read "partial" forever — the constraint is non-applicable,
// not "not internal yet." Same skip applied in
// `toggleBundleInternal` so the cascade matches the aggregation.
if (!isInternalable(mods[i])) continue;
total++;
if (isInternal(mods[i])) on++;
}
if (total === 0) return null;
if (on === 0) return "none";
if (on === total) return "all";
return "partial";
}
/** Same shape over `locked_seed`, but only the seed-lockable
* children count. Returns `null` when the bundle has zero lockable
* children — BundleHeader uses that to hide the lock button so a
* bundle of fixed_values + constraints (which can't be seed-locked)
* doesn't render a no-op control. */
function bundleLockState(
bundle: BundleInstance,
): "all" | "none" | "partial" | null {
const mods = value.value.modules;
let on = 0;
let total = 0;
for (let i = bundle.start_idx; i <= bundle.end_idx; i++) {
if (!isSeedLockable(mods[i])) continue;
total++;
if (isLocked(mods[i])) on++;
}
if (total === 0) return null;
if (on === 0) return "none";
if (on === total) return "all";
return "partial";
}
/** OFF resolution mode for nested master cascades.
*
* - "preserve" → only rows whose `master_internal_by` matches this
* bundle's `_uid` get cleared. Manual-internal rows
* survive. Inner masters reached via recursion ALSO
* run in preserve mode regardless of their own
* claims, so a parent's OFF doesn't accidentally
* sweep an inner bundle's manual rows.
* - "cascade" → every internal row this bundle owns (direct
* children) clears unconditionally. Triggered only
* when the entire scope is hand-marked (no master
* claim anywhere this bundle could see) and the
* user clicks the master button at "all" — they
* almost certainly meant "turn everything off".
* Inner masters receive the same "cascade" so
* inner-owned manual rows clear too. */
type BundleOffMode = "preserve" | "cascade";
/** Set `instance.internal` for every direct child of a bundle, then
* recurse into inner bundles. See `BundleOffMode` for the OFF
* decision matrix.
*
* A bundle's master governs ONLY its direct children — rows owned
* by an inner bundle are skipped + the recursive call propagates
* the same on/off + mode downward. */
function setBundleInternal(
uid: string,
targetOn: boolean,
forceMode?: BundleOffMode,
): void {
const bundles = value.value.bundles ?? [];
const bundle = bundles.find((b) => b._uid === uid);
if (!bundle) return;
const innerBundles = bundles.filter((b) => b.parent_uid === bundle._uid);
const innerUids = new Set(innerBundles.map((b) => b._uid));
const modules = value.value.modules;
// OFF-mode decision (forceMode wins on recursive descent). Scan
// the entire scope for any `master_internal_by` tag — presence
// anywhere → preserve (some master claimed something, OFF
// should undo only that). Absence anywhere → cascade (all
// hand-marked, click OFF at "all" means "really clear all").
let mode: BundleOffMode = forceMode ?? "preserve";
if (!targetOn && !forceMode) {
let hasAnyClaim = false;
for (let i = bundle.start_idx; i <= bundle.end_idx; i++) {
const m = modules[i];
if (!m || !isInternalable(m)) continue;
if (m.instance?._ui?.master_internal_by) {
hasAnyClaim = true;
break;
}
}
mode = hasAnyClaim ? "preserve" : "cascade";
}
// Track which inners this call should recurse into. Two rules:
// ON — recurse if the inner state was NOT "all" before this op
// (we're flipping it from partial/none to all). Tag the
// inner with `master_internal_chained_by = bundle._uid`
// so its eventual OFF can find us. If inner was already
// "all", skip — user/its own master got there independently,
// we don't want to "own" inner's pre-existing state.
// OFF — recurse only if inner.master_internal_chained_by ===
// bundle._uid (we previously chained inner ON). Otherwise
// leave inner alone (user-controlled). On recursion, clear
// the chain tag.
type InnerOp = { inner: BundleInstance; chainTag: "set" | "clear" | null };
const innerOps: InnerOp[] = [];
for (const inner of innerBundles) {
if (targetOn) {
const innerState = bundleInternalState(inner);
if (innerState !== "all") {
innerOps.push({ inner, chainTag: "set" });
}
} else {
if (inner.master_internal_chained_by === bundle._uid) {
innerOps.push({ inner, chainTag: "clear" });
}
}
}
const list = modules.map((m, i) => {
if (i < bundle.start_idx || i > bundle.end_idx) return m;
if (!isInternalable(m)) return m;
const origin = (m as ModuleEntry & { bundle_origin?: string }).bundle_origin;
if (origin && innerUids.has(origin)) return m;
const inst = m.instance ?? {};
const ui = inst._ui ?? {};
if (targetOn) {
if (inst.internal) return m;
return {
...m,
instance: {
...inst,
internal: true,
_ui: { ...ui, master_internal: true, master_internal_by: bundle._uid },
},
};
} else {
if (!inst.internal) return m;
// Preserve mode → only clear rows THIS bundle owns. Cascade
// mode → clear everything in scope regardless of claim.
if (mode === "preserve" && ui.master_internal_by !== bundle._uid) return m;
const { internal: _drop, ...restInst } = inst;
void _drop;
const { master_internal: _drop2, master_internal_by: _drop3, ...restUi } = ui;
void _drop2;
void _drop3;
return { ...m, instance: { ...restInst, _ui: restUi } };
}
});
// Apply chain-tag updates to inner BundleInstances alongside the
// module commit. Single commitModules call keeps modules + bundles
// in lock-step so a subsequent recursive call sees the new flags.
const tagsByUid = new Map<string, "set" | "clear">();
for (const op of innerOps) if (op.chainTag) tagsByUid.set(op.inner._uid, op.chainTag);
const nextBundles = bundles.map((b) => {
const tag = tagsByUid.get(b._uid);
if (!tag) return b;
if (tag === "set") return { ...b, master_internal_chained_by: bundle._uid };
const { master_internal_chained_by: _drop, ...rest } = b;
void _drop;
return rest as BundleInstance;
});
commitModules(list, nextBundles);
// Recurse into inner bundles selected above. Mode propagates so a
// preserving outer doesn't turn into a cascading inner.
for (const op of innerOps) {
setBundleInternal(op.inner._uid, targetOn, mode);
}
}
/** Click handler: aggregation drives the on/off resolution. State
* "all" → click OFF (sweep everything in scope); state "none" or
* "partial" → click ON (claim every off row + propagate to inner
* masters). User-initiated click also clears this bundle's own
* `master_internal_chained_by` flag — taking direct action means
* the bundle is no longer "owned" by an outer chain. Any future
* outer OFF will leave it alone. */
function toggleBundleInternal(uid: string): void {
const bundles = value.value.bundles ?? [];
const bundle = bundles.find((b) => b._uid === uid);
if (!bundle) return;
const state = bundleInternalState(bundle);
if (state === null) return;
if (bundle.master_internal_chained_by) {
const nextBundles = bundles.map((b) => {
if (b._uid !== uid) return b;
const { master_internal_chained_by: _drop, ...rest } = b;
void _drop;
return rest as BundleInstance;
});
value.value = { ...value.value, bundles: nextBundles };
}
setBundleInternal(uid, state !== "all");
}
/** Set seed-lock for every direct child of a bundle, recurse into
* inner bundles. Mirrors `setBundleInternal` — see that fn for the
* full design rationale (inner-bundle ownership, smart OFF,
* cascade-via-recursion). Lock-specific bit: ON uses the
* per-instance last-used seed via `lastUsedSeedReader`, then the
* `_ui.last_locked_seed` memory, then 0. */
function setBundleLock(
uid: string,
targetOn: boolean,
forceMode?: BundleOffMode,
): void {
const bundles = value.value.bundles ?? [];
const bundle = bundles.find((b) => b._uid === uid);
if (!bundle) return;
const innerBundles = bundles.filter((b) => b.parent_uid === bundle._uid);
const innerUids = new Set(innerBundles.map((b) => b._uid));
const modules = value.value.modules;
// Same OFF-mode decision matrix as setBundleInternal — see
// `BundleOffMode` docs above for the full table. Pre-scan covers
// the ENTIRE scope (not just direct children) so an inner-owned
// claim correctly forces outer's OFF into preserve mode and
// saves any hand-locked rows in outer's direct scope.
let mode: BundleOffMode = forceMode ?? "preserve";
if (!targetOn && !forceMode) {
let hasAnyClaim = false;
for (let i = bundle.start_idx; i <= bundle.end_idx; i++) {
const m = modules[i];
if (!m || !isSeedLockable(m)) continue;
if (m.instance?._ui?.master_lock_by) {
hasAnyClaim = true;
break;
}
}
mode = hasAnyClaim ? "preserve" : "cascade";
}
// Inner chain decisions mirror setBundleInternal. See that fn's
// commentary for the full rationale.
type InnerOp = { inner: BundleInstance; chainTag: "set" | "clear" | null };
const innerOps: InnerOp[] = [];
for (const inner of innerBundles) {
if (targetOn) {
const innerState = bundleLockState(inner);
if (innerState !== "all") {
innerOps.push({ inner, chainTag: "set" });
}
} else {
if (inner.master_lock_chained_by === bundle._uid) {
innerOps.push({ inner, chainTag: "clear" });
}
}
}
const list = modules.map((m, i) => {
if (i < bundle.start_idx || i > bundle.end_idx) return m;
if (!isSeedLockable(m)) return m;
const origin = (m as ModuleEntry & { bundle_origin?: string }).bundle_origin;
if (origin && innerUids.has(origin)) return m;
const inst = m.instance ?? {};
const ui = inst._ui ?? {};
if (targetOn) {
if (typeof inst.locked_seed === "number") return m;
let fallback: number;
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
if (typeof lastUsed === "number") fallback = lastUsed;
else if (typeof ui.last_locked_seed === "number") fallback = ui.last_locked_seed;
else fallback = 0;
return {
...m,
instance: {
...inst,
locked_seed: fallback,
_ui: {
...ui,
last_locked_seed: fallback,
master_lock: true,
master_lock_by: bundle._uid,
},
},
};
} else {
if (typeof inst.locked_seed !== "number") return m;
if (mode === "preserve" && ui.master_lock_by !== bundle._uid) return m;
const { master_lock: _drop, master_lock_by: _drop2, ...restUi } = ui;
void _drop;
void _drop2;
// DROP the `locked_seed` key entirely — earlier we set it to
// `null`, which the engine treats identically to missing but
// the bundle fingerprint distinguishes (stableStringify emits
// `"locked_seed":null` vs no key at all).
const { locked_seed: _drop3, ...restInstClean } = inst;
void _drop3;
return {
...m,
instance: { ...restInstClean, _ui: restUi },
};
}
});
const tagsByUid = new Map<string, "set" | "clear">();
for (const op of innerOps) if (op.chainTag) tagsByUid.set(op.inner._uid, op.chainTag);
const nextBundles = bundles.map((b) => {
const tag = tagsByUid.get(b._uid);
if (!tag) return b;
if (tag === "set") return { ...b, master_lock_chained_by: bundle._uid };
const { master_lock_chained_by: _drop, ...rest } = b;
void _drop;
return rest as BundleInstance;
});
commitModules(list, nextBundles);
for (const op of innerOps) {
setBundleLock(op.inner._uid, targetOn, mode);
}
}
function toggleBundleLock(uid: string): void {
const bundles = value.value.bundles ?? [];
const bundle = bundles.find((b) => b._uid === uid);
if (!bundle) return;
const state = bundleLockState(bundle);
if (state === null) return;
// Direct user click clears this bundle's own chain tag — taking
// ownership decouples it from any outer chain.
if (bundle.master_lock_chained_by) {
const nextBundles = bundles.map((b) => {
if (b._uid !== uid) return b;
const { master_lock_chained_by: _drop, ...rest } = b;
void _drop;
return rest as BundleInstance;
});
value.value = { ...value.value, bundles: nextBundles };
}
setBundleLock(uid, state !== "all");
}
/** Non-empty array helper — null/undefined/empty all read as "no override". */
function nonEmptyArr(v: unknown): boolean {
return Array.isArray(v) && v.length > 0;
}
/** Non-empty object helper — null/undefined/{} all read as "no override". */
function nonEmptyObj(v: unknown): boolean {
return v !== null && typeof v === "object" && !Array.isArray(v)
&& Object.keys(v as Record<string, unknown>).length > 0;
}
function isModified(m: ModuleEntry): boolean {
// Lock + internal have their own dedicated header buttons, so
// double-counting them in the "modified" dot is just visual
// noise. The modified indicator is reserved for state that
// diverges from the library snapshot.
const inst = m.instance;
if (!inst) return false;
switch (m.type) {
case "wildcard":
// Pool overrides — anything that changes WHICH option a roll picks.
// Identity (display name + variable_binding) is intentionally
// excluded: those have their own per-field reset icon inside the
// modal, and counting them in the outer mod dot would conflate
// identity-level renames with behavioural overrides.
if (nonEmptyArr(inst.enabled_options)) return true;
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;
return false;
case "fixed_values":
// Library-tracked: `values_overrides` non-empty = user edited entries,
// OR `enabled_options` set means user toggled row enable/disable.
// Inline-created (no payload_hash) never light up — no library anchor.
if (nonEmptyArr((inst as { values_overrides?: unknown }).values_overrides)) return true;
if (Array.isArray(inst.enabled_options)) return true;
return false;
case "combine":
// Template override only. Identity (binding) excluded — see
// wildcard branch comment.
if (typeof inst.template_override === "string" && inst.template_override.length > 0) return true;
return false;
case "derivation":
if (nonEmptyArr(inst.disabled_rule_ids)) return true;
if (nonEmptyArr(inst.disabled_branch_keys)) return true;
if (nonEmptyObj(inst.action_value_overrides)) return true;
if (nonEmptyObj(inst.condition_value_overrides)) return true;
if (nonEmptyArr(inst.rule_order_override)) return true;
return false;
case "constraint":
if (nonEmptyArr(inst.disabled_exception_keys)) return true;
if (nonEmptyArr(inst.disabled_matrix_cells)) return true;
if (nonEmptyObj(inst.cell_mode_overrides)) return true;
if (nonEmptyObj(inst.cell_factor_overrides)) return true;
if (nonEmptyObj(inst.exception_mode_overrides)) return true;
if (nonEmptyObj(inst.exception_factor_overrides)) return true;
if (nonEmptyArr(inst.extra_exceptions)) return true;
return false;
default:
return false;
}
}
/** Kinds whose engine handler honors `instance.locked_seed`. Wildcard
* pins option pick; combine pins `{a|b|c}` template resolution;
* fixed_values pins per-value alternation; derivation pins inline
* syntax resolution inside `action.value` (`{a|b|c}` + nested
* wildcards) — see derivation_handler.resolve. Other kinds
* (constraint) ignore the field — engine doesn't read it.
* Inline lock action gates on this set so the icon only appears
* where it actually does something. */
const SEED_LOCKABLE_KINDS: ReadonlySet<string> = new Set([
"wildcard", "combine", "fixed_values", "derivation",
]);
function isSeedLockable(m: ModuleEntry): boolean {
return SEED_LOCKABLE_KINDS.has(m.type);
}
/** True for kinds that produce bindings — i.e. everything EXCEPT
* constraint. Constraint modules don't write any `$var`; they only
* constrain the relationship between two existing wildcards, so the
* `instance.internal` flag has no surface to hide and toggling it
* would be a no-op. Bundle master-toggle aggregation and the
* per-card internal button both gate on this. */
function isInternalable(m: ModuleEntry): boolean {
return m.type !== "constraint";
}
function isLocked(m: ModuleEntry): boolean {
return typeof m.instance?.locked_seed === "number";
}
function isInternal(m: ModuleEntry): boolean {
return !!m.instance?.internal;
}
// ── Formula-driven minWidth ────────────────────────────────────────
// Pull-based pattern: createDomWidgetHost reads this through a
// getter, litegraph queries each layout pass, no observer required.
// Module rows have a flex:1 `wp-module-name` that absorbs slack —
// adding a conflict badge or state badge doesn't widen the row,
// just shrinks the name's render area. So the formula adjusts up
// when badges are present so the name keeps useful breathing room
// (without state-driven width, a row with name "long_module_alias"
// + conflict badge + actions would clip the name to ~30px when
// node is at the minimum).
//
// Base 380 covers the footer's two "+ Add module" / "+ Add Bundle"
// buttons side-by-side, which is the widest mandatory element. Per-
// badge bumps give the name room to breathe.
const hasAnyConflict = computed(() => Object.keys(conflictsByModule.value).length > 0);
const hasAnyStateBadge = computed(() =>
value.value.modules.some((m) => isModified(m) || isDrifted(m) || isMissingFromLibrary(m)),
);
const requiredMinWidth = computed(() => {
let w = 380; // footer-driven baseline
if (hasAnyConflict.value) w += 60;
if (hasAnyStateBadge.value) w += 60;
return w;
});
watch(
requiredMinWidth,
(next) => {
props.onRequestMinWidth?.(next);
},
{ immediate: true },
);
/** In-card lock toggle. Off → null `locked_seed` but keep
* `_ui.last_locked_seed` so the next toggle-on has a fallback.
* On → fallback chain (per-module priority so re-locking captures
* what THIS specific wildcard actually rolled with):
* 1. lastUsedSeedReader(m._uid) — seed THIS instance used last
* run (locked_seed if it was locked, else chain seed).
* Keyed by per-instance _uid so siblings sharing m.id don't
* collapse to one value.
* Refreshes after every queue. Handles the
* lock→run→unlock→lock case correctly: the locked-and-then-
* unlocked wildcard restores to ITS locked seed, not the
* chain seed of that run.
* 2. `_ui.last_locked_seed` — cold-start fallback when no queue
* has happened yet this session.
* 3. 0 (final default). */
function toggleLockOnCard(idx: number) {
const m = value.value.modules[idx];
if (!m) return;
const inst = m.instance ?? {};
let nextInst: NonNullable<ModuleEntry["instance"]>;
if (typeof inst.locked_seed === "number") {
// DROP `locked_seed` rather than setting `null` — bundle MOD
// fingerprint stableStringify distinguishes `null` from missing,
// and a bundle was lighting up "modified" on the first per-card
// unlock because the JSON shape changed even though the engine
// treats both states identically.
const { locked_seed: _drop, ...restInst } = inst;
void _drop;
nextInst = restInst;
} else {
let fallback: number;
// Read by per-instance `_uid` — sibling rows share `m.id` (library
// uuid) so id-keyed reads would return the same value for every
// sibling regardless of which one rolled last. Engine trace now
// emits `_uid` per entry; `module_seeds` is keyed by it. Falls back
// to `m.id` for pre-`_uid` migration entries.
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
if (typeof lastUsed === "number") {
fallback = lastUsed;
} else if (typeof inst._ui?.last_locked_seed === "number") {
fallback = inst._ui.last_locked_seed;
} else {
fallback = 0;
}
nextInst = { ...inst, locked_seed: fallback, _ui: { ...inst._ui, last_locked_seed: fallback } };
}
// Drop the bundle master_lock marker — the user is now hand-
// managing this row's lock state, so a future master OFF on the
// bundle should leave it alone. Strips both legacy `master_lock`
// (boolean) and the per-bundle `master_lock_by` (uid) tag.
if (nextInst._ui && (nextInst._ui.master_lock || nextInst._ui.master_lock_by)) {
const { master_lock: _drop, master_lock_by: _drop2, ...restUi } = nextInst._ui;
void _drop;
void _drop2;
nextInst = { ...nextInst, _ui: restUi };
}
// Phase B: index-based mutation — sibling rows share `m.id`, so a
// map-by-id pass would toggle every sibling at once. Indexing into
// the array hits the specific instance the user clicked.
const list = [...value.value.modules];
list[idx] = { ...m, instance: nextInst };
commitModules(list);
}
/** In-card internal toggle. Drops the field on toggle-off so the
* persisted JSON stays minimal (matches the modal). */
function toggleInternalOnCard(idx: number) {
const m = value.value.modules[idx];
if (!m) return;
const inst = m.instance ?? {};
// Dropping the `master_internal` markers (legacy boolean + per-bundle
// `master_internal_by` uid tag) means the bundle master's OFF
// cascade won't revert this row — the user is now hand-managing it.
// Whether they're turning it on for the first time or off after
// the master had set it, ownership transfers here.
const ui = inst._ui ?? {};
const { master_internal: _dropMarker, master_internal_by: _dropMarker2, ...restUi } = ui;
void _dropMarker;
void _dropMarker2;
const nextUi = restUi;
let nextInst: NonNullable<ModuleEntry["instance"]>;
if (inst.internal) {
const { internal: _drop, ...rest } = inst;
void _drop;
nextInst = { ...rest, _ui: nextUi };
} else {
nextInst = { ...inst, internal: true, _ui: nextUi };
}
const list = [...value.value.modules];
list[idx] = { ...m, instance: nextInst };
commitModules(list);
}
/** Tooltip listing what's been overridden on the module — surfaced
* on hover of the modified-indicator dot. Order matches each kind's
* modal section layout for visual consistency. */
function modifiedTooltip(m: ModuleEntry): string {
const inst = m.instance;
if (!inst) return "";
const bits: string[] = [];
switch (m.type) {
case "wildcard":
if (inst.mode === "pinned") bits.push("pinned");
else if (inst.mode === "subcategory") bits.push("subset");
if (inst.category_filter) {
bits.push(`cats: ${inst.category_filter}`);
}
if (Array.isArray(inst.enabled_options)) {
bits.push(`${inst.enabled_options.length} option(s) enabled`);
}
if (nonEmptyObj(inst.option_weights)) {
bits.push(`${Object.keys(inst.option_weights as Record<string, unknown>).length} weight override(s)`);
}
break;
case "fixed_values": {
const overrides = (inst as { values_overrides?: unknown[] }).values_overrides;
if (Array.isArray(overrides) && overrides.length > 0) {
bits.push(`${overrides.length} value override(s)`);
}
if (Array.isArray(inst.enabled_options)) {
bits.push(`${inst.enabled_options.length} row(s) enabled`);
}
break;
}
case "combine":
if (typeof inst.template_override === "string" && inst.template_override.length > 0) {
bits.push("template override");
}
if (typeof inst.variable_binding === "string" && inst.variable_binding.length > 0) {
bits.push(`binding: ${inst.variable_binding}`);
}
break;
case "derivation":
if (nonEmptyArr(inst.disabled_rule_ids)) bits.push(`${(inst.disabled_rule_ids as string[]).length} rule(s) disabled`);
if (nonEmptyArr(inst.disabled_branch_keys)) bits.push(`${(inst.disabled_branch_keys as string[]).length} branch(es) disabled`);
if (nonEmptyObj(inst.action_value_overrides)) bits.push("action overrides");
if (nonEmptyObj(inst.condition_value_overrides)) bits.push("condition overrides");
if (nonEmptyArr(inst.rule_order_override)) bits.push("reordered");
break;
case "constraint": {
const cellOverrides =
(nonEmptyObj(inst.cell_mode_overrides) ? Object.keys(inst.cell_mode_overrides as object).length : 0)
+ (nonEmptyObj(inst.cell_factor_overrides) ? Object.keys(inst.cell_factor_overrides as object).length : 0);
if (cellOverrides > 0) bits.push(`${cellOverrides} cell override(s)`);
if (nonEmptyArr(inst.disabled_matrix_cells)) bits.push(`${(inst.disabled_matrix_cells as string[]).length} cell(s) disabled`);
if (nonEmptyArr(inst.disabled_exception_keys)) bits.push(`${(inst.disabled_exception_keys as string[]).length} exception(s) disabled`);
const excOverrides =
(nonEmptyObj(inst.exception_mode_overrides) ? Object.keys(inst.exception_mode_overrides as object).length : 0)
+ (nonEmptyObj(inst.exception_factor_overrides) ? Object.keys(inst.exception_factor_overrides as object).length : 0);
if (excOverrides > 0) bits.push(`${excOverrides} exception override(s)`);
if (nonEmptyArr(inst.extra_exceptions)) bits.push(`${(inst.extra_exceptions as unknown[]).length} extra exception(s)`);
break;
}
}
return bits.length > 0 ? `Modified: ${bits.join(" · ")}` : "Modified";
}
/**
* Look up a sibling module's display name by uuid. Used by the
* constraint summary + (Task 2) the modal preview. Returns the
* payload's `var_binding` first (canonical $-var name for wildcards),
* falls back to `meta.name`, then to the lazy preview-resolver cache
* for cross-node refs (e.g. constraint source/target wildcards that
* weren't picked into this WP_Context). Returns `null` only when
* neither siblings nor the cache know the uuid — caller decides what
* to render in that dangling-ref case.
*/
function lookupSiblingName(uuid: string | null | undefined): string | null {
if (!uuid) return null;
// Touch cacheVersion so the computed re-evaluates when an async
// fetch lands. Without this read the lookup stays stale until some
// other dependency invalidates the computed.
void previewCacheVersion.value;
const sib = value.value.modules.find((m) => m.id === uuid);
if (sib) {
// Mirror the engine's precedence: per-instance override wins
// (`wildcard_handler.py:216`), payload default falls through.
const inst = sib.instance?.variable_binding;
if (typeof inst === "string" && inst.trim()) return inst.trim();
const binding = (sib.payload as { var_binding?: string } | undefined)?.var_binding;
if (typeof binding === "string" && binding.trim()) return binding.trim();
const name = sib.meta?.name?.trim();
if (name) return name;
}
// Fallback: dangling ref (e.g. constraint references a wildcard
// not embedded in this node). Pull the canonical name from the
// preview-resolver cache — that's already populated for any uuid
// mentioned anywhere in the chain. Fire ensure() lazily so the
// first reference triggers a fetch; subsequent reads hit the cache.
ensurePreviewLookup([uuid]);
const lk = previewLookup(uuid);
if (lk?.varBinding) return lk.varBinding;
if (lk?.name) return lk.name;
return null;
}
/**
* Returns null when the module is the only instance of its uuid in the
* current Context, or `{ index: 1-based, total }` when it has siblings.
* Used to render the `#N of M` badge.
*
* Phase A: counts in-Context only. Phase B may broaden to whole-
* workflow scope when wiring the auto-fork prompt.
*/
function siblingInfo(m: ModuleEntry): { index: number; total: number } | null {
const sameUuid = value.value.modules.filter((x) => x.id === m.id);
if (sameUuid.length <= 1) return null;
const index = sameUuid.findIndex((x) => x === m) + 1;
return { index, total: sameUuid.length };
}
/**
* One token of the summary line. `var` tokens get the kind-color
* `var-N` class via `varColorClass`; `literal` tokens render plain.
*
* Same shape as the assembler / combine preview tokenization
* (`wp-asm-preview` in AssemblerHelper.vue) so the eye reads
* `$hair_style` as the same hue everywhere it appears.
*/
type SummaryToken =
| { kind: "literal"; text: string }
| { kind: "var"; text: string; varName: string };
/**
* Structured per-module summary used by the row's meta line. Each
* entry is rendered as either a colored `<span class="var-tok var-N">`
* (for `$binding` references) or plain text. Shape mirrors the
* mockup at `_temp/design-demos/widgets-redesign-v5.html:682, 697,
* 712, 723, 734` so colored variable tokens land in-place of the
* previous all-plaintext meta string.
*/
function summaryTokens(m: ModuleEntry): SummaryToken[] {
// Tiny helper — wrap a binding name as a `$name` var token so the
// colour hash is computed against the bare name (matching how
// `varColorClass` is used elsewhere in the codebase).
const v = (name: string): SummaryToken => ({ kind: "var", text: `$${name}`, varName: name });
const lit = (text: string): SummaryToken => ({ kind: "literal", text });
if (m.type === "fixed_values") {
// Read the bindings from `entries` first — that's the UI-mirror
// populated when the user types directly into the inline editor.
// Library-linked rows (bundle children, modules dragged in fresh
// from the picker) ship with `entries: []` because the canonical
// values live in `payload.values`. Fall back to that payload
// shape so a library-linked fixed_values row doesn't render as
// "(empty)" just because its UI-mirror hasn't been populated.
const fromEntries = m.entries
.map((e) => e.variable_name.trim())
.filter((n) => n !== "");
const fromPayload =
fromEntries.length > 0
? []
: (((m.payload ?? {}) as { values?: Array<{ name?: string }> }).values ?? [])
.map((vv) => (vv.name ?? "").trim())
.filter((n) => n !== "");
const names = fromEntries.length > 0 ? fromEntries : fromPayload;
if (names.length === 0) return [lit("(empty)")];
const heads = names.slice(0, 2);
const more = names.length - heads.length;
const out: SummaryToken[] = [];
heads.forEach((name, i) => {
if (i > 0) out.push(lit(", "));
out.push(v(name));
});
if (more > 0) out.push(lit(`, +${more} more`));
return out;
}
const p = (m.payload ?? {}) as Record<string, unknown>;
switch (m.type) {
case "wildcard": {
// 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 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 head: SummaryToken = binding ? v(binding) : lit("wildcard");
return opts
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
: [head];
}
case "combine": {
// Same per-instance precedence as wildcard: rebinding via the v2
// modal (`instance.variable_binding`) renders as `$<override>` on
// the card so the canvas reads match the engine output.
const inst = m.instance?.variable_binding;
const payloadOut = (p.output_var as string | undefined)?.trim();
const out = (typeof inst === "string" && inst.trim()) || payloadOut;
return out ? [lit("→ "), v(out)] : [lit("template")];
}
case "derivation": {
const rules = Array.isArray(p.rules) ? p.rules.length : 0;
return [lit(`${rules} rule${rules === 1 ? "" : "s"}`)];
}
case "constraint": {
const cp = p as Partial<{
source_wildcard_id: string | null;
target_wildcard_id: string | null;
matrix: Record<string, Record<string, unknown>>;
}>;
const src = lookupSiblingName(cp.source_wildcard_id) ?? "?";
const tgt = lookupSiblingName(cp.target_wildcard_id) ?? "?";
const rowKeys = Object.keys(cp.matrix ?? {});
const colKeys = Object.keys(Object.values(cp.matrix ?? {})[0] ?? {});
return [v(src), lit(" → "), v(tgt), lit(` · ${rowKeys.length}×${colKeys.length}`)];
}
default: return [lit(m.type)];
}
}
/** Plain-text fallback — used for `:title` tooltip + a11y text. */
function summaryFor(m: ModuleEntry): string {
return summaryTokens(m).map((t) => t.text).join("");
}
/**
* Normalize a module type into the slug used by the
* `--wp-kind-{slug}` CSS tokens (see `shared/theme.css`). The engine
* uses `fixed_values` but the colour token is `--wp-kind-fixed`, so
* the kind chip needs a small alias map. Other kinds pass through
* unchanged so adding a new kind requires only a token + a
* `KIND_TITLE` entry, no chip-side change.
*/
function kindChipModifier(kind: string): string {
return kind === "fixed_values" ? "fixed" : kind;
}
/**
* Handle a picker `pick` event.
*
* The picker emits an array of library UUIDs. We POST them to the
* embed-bundle endpoint and append a `ModuleEntry` for each returned
* module to `value.modules` — uniformly with how inline-created
* fixed_values land in the same array. There is no separate
* snapshots / catalog list in the workflow JSON; the runtime side
* builds its `__wp_catalog__` by filtering `modules` for wildcards.
*
* Already-present uuids are skipped (no duplicate cards). Server
* walks transitive `@{}` deps and returns them in the same `modules`
* array — they each become their own card so the user sees what
* was auto-included.
*/
async function onPickerAdd(ids: string[]) {
if (ids.length === 0) return;
await onLibraryPick(ids);
}
async function onLibraryPick(uuids: string[]) {
if (uuids.length === 0) {
showPicker.value = false;
return;
}
try {
const res = await fetch("/wp/api/modules/embed-bundle", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uuids }),
});
if (!res.ok) {
throw new Error(`Embed bundle failed (HTTP ${res.status}).`);
}
const bundle = (await res.json()) as {
modules?: Record<string, unknown>[];
snapshots?: Record<string, {
uuid: string;
type: string;
name: string;
payload: Record<string, unknown>;
payload_hash: string;
}>;
pickOrder?: string[];
walkOverflow?: { uuid: string; reason: string }[];
};
// The server returns:
// - `modules`: full payloads of explicit picks (executor view)
// - `snapshots`: catalog map keyed by uuid (picks + transitive
// wildcards walked via `@{}` refs)
//
// For the unified-list UI we want one card per snapshot — picks
// AND transitive deps both render as full cards so the user sees
// exactly what got embedded. Walk `snapshots` for the union.
const snaps = bundle.snapshots ?? {};
const incomingOrder = bundle.pickOrder ?? Object.keys(snaps);
const existingIds = new Set(value.value.modules.map((m) => m.id));
const newEntries: ModuleEntry[] = [];
// Hoist `payload.values` → `entries` for fixed_values so the
// widget UI (collapsed summary, modal entries editor) finds the
// bindings. Engine reads from `payload.values` directly so this
// is purely a UI-side mirror; the modal's save() step keeps the
// two in sync after edits.
function entriesFromSnapshot(
entry: { type: string; payload: Record<string, unknown> },
): { variable_name: string; value: string }[] {
if (entry.type !== "fixed_values") return [];
const values = (entry.payload as { values?: unknown }).values;
if (!Array.isArray(values)) return [];
return values
.filter((v): v is Record<string, unknown> => typeof v === "object" && v !== null)
.map((v) => ({
variable_name: String(v.name ?? v.var ?? ""),
value: String(v.value ?? ""),
}));
}
// Honor wp.collapsedByDefault + wp.newModuleDisabled — if the
// user opted into either, every newly-embedded module respects
// the preference. Existing modules retain their previous state
// because we only mutate `newEntries` here, not existing cards.
//
// Accordion mode wins over collapsedByDefault for new adds: only
// one module can be expanded at a time, and we don't know which
// freshly-added module the user wants expanded — so start them
// all collapsed and let the user click to expand whichever they
// need. This keeps the accordion invariant (max 1 expanded) intact.
const startCollapsed = getCollapseMode() === "accordion" ? true : getCollapsedByDefault();
const startDisabled = getNewModuleDisabled();
// Append picks first (in input order). Phase B: same-uuid picks
// become siblings with an auto-suffixed binding (pre-B silently
// skipped them).
const seenInBundle = new Set<string>();
const existingBindings = collectInContextBindings(value.value.modules);
for (const uuid of incomingOrder) {
if (seenInBundle.has(uuid)) continue;
const entry = snaps[uuid];
if (!entry) continue;
seenInBundle.add(uuid);
const isSibling = existingIds.has(uuid);
const newEntry: ModuleEntry = {
id: uuid,
_uid: newRowUid(),
type: entry.type as ModuleEntry["type"],
enabled: !startDisabled,
meta: { name: entry.name, library_name: entry.name },
entries: entriesFromSnapshot(entry),
payload: entry.payload,
payload_hash: entry.payload_hash,
collapsed: startCollapsed,
};
if (isSibling) {
const baseBinding = extractPrimaryBinding(newEntry);
if (baseBinding) {
const suffixed = nextBindingSuffix(baseBinding, existingBindings);
newEntry.instance = {
...(newEntry.instance ?? {}),
variable_binding: suffixed,
};
existingBindings.add(suffixed);
}
}
newEntries.push(newEntry);
}
// Then any transitive deps the walker pulled in but that weren't
// in the explicit pickOrder.
for (const [uuid, entry] of Object.entries(snaps)) {
if (existingIds.has(uuid) || seenInBundle.has(uuid)) continue;
seenInBundle.add(uuid);
newEntries.push({
id: uuid,
_uid: newRowUid(),
type: entry.type as ModuleEntry["type"],
enabled: !startDisabled,
meta: { name: entry.name, library_name: entry.name },
entries: entriesFromSnapshot(entry),
payload: entry.payload,
payload_hash: entry.payload_hash,
collapsed: startCollapsed,
});
}
if (newEntries.length === 0) {
pushToast(
"Already embedded — nothing new added.",
{ severity: "info", lifeMs: 2500 },
);
showPicker.value = false;
return;
}
commitModules([...value.value.modules, ...newEntries]);
// Phase B.6: fade-in + slide-X each newly added row. No FLIP capture
// here — new rows are appended at the tail so existing rows don't
// shift. Two-pass batched class lifecycle ensures every row arrives
// simultaneously instead of in a staircase.
await nextTick();
if (modulesContainer.value) {
await animateEnterBatch(
newEntries.map(e => e._uid),
modulesContainer.value,
);
}
const overflow = bundle.walkOverflow ?? [];
if (overflow.length > 0) {
const cycles = overflow.filter((o) => o.reason === "cycle_detected").length;
const missing = overflow.filter((o) => o.reason === "missing_target").length;
const depth = overflow.filter((o) => o.reason === "max_depth").length;
const parts: string[] = [];
if (cycles) parts.push(`${cycles} cycle${cycles === 1 ? "" : "s"}`);
if (missing) parts.push(`${missing} missing`);
if (depth) parts.push(`${depth} depth-cap`);
pushToast(
`Embedded ${newEntries.length} module${newEntries.length === 1 ? "" : "s"} (walker noted: ${parts.join(", ")}).`,
{ severity: "warning", lifeMs: 5000 },
);
} else {
pushToast(
`Embedded ${newEntries.length} module${newEntries.length === 1 ? "" : "s"} into the workflow.`,
{ severity: "success", lifeMs: 3000 },
);
}
} catch (e) {
pushToast(
e instanceof Error ? e.message : "Embed failed.",
{ severity: "error", lifeMs: 5000 },
);
} finally {
showPicker.value = false;
}
}
async function removeModule(idx: number): Promise<void> {
// Soft-delete: capture position + module, drop a toast with Undo. Undo
// splices it back at its original index. Phase B: removes by index so
// sibling rows (same uuid, multiple instances) only delete the
// specific row the user clicked. Pre-Phase-B `filter(m => m.id !== id)`
// would have removed every sibling at once.
if (idx < 0 || idx >= value.value.modules.length) return;
const removed = value.value.modules[idx];
const moduleLabel = removed.meta.name?.trim() || "module";
// Phase B.6: animate row out with --leaving, then commit the splice.
// FLIP captures pre-mutation rects so sibling rows below slide up to
// close the gap once the row is gone.
const uid = removed._uid;
if (uid) {
await withLeaveAnimation(uid, modulesContainer.value ?? document.body, () => {
// No-op — mutation happens below after the await resolves.
});
}
// Snapshot bundles BEFORE the remove so the Undo path can restore
// a bundle that dissolved when its last child left. Without this,
// reconcile on undo can only revive bundles still present in the
// current bundles[]; a dissolved single-child bundle would leave
// the restored module orphaned outside the frame.
const prevBundles = value.value.bundles ?? [];
// Anchor for Undo: the _uid of the module that NOW sits at
// `idx + 1` (i.e. the neighbor immediately after the row we're
// about to remove). Splicing relative to this anchor on Undo lands
// the restored row in the right slot even if sibling ops shifted
// things between remove and click. Falls back to clamped idx when
// the anchor is gone too — degrades gracefully rather than failing.
const anchorAfter = value.value.modules[idx + 1]?._uid ?? null;
const flipSnap = captureFlipSnapshot();
const next = [...value.value.modules];
next.splice(idx, 1);
// Bundle-range integrity: removing a module from inside a bundle
// must shrink that bundle's end_idx (one fewer child). Bundles
// that started AFTER the removed index shift left by one. Bundles
// whose range collapses to empty dissolve entirely.
const nextBundles = (value.value.bundles ?? [])
.map((b) => {
if (b.start_idx <= idx && idx <= b.end_idx) {
const newEnd = b.end_idx - 1;
if (newEnd < b.start_idx) return null; // empty → dissolve
return { ...b, end_idx: newEnd };
}
if (b.start_idx > idx) {
return { ...b, start_idx: b.start_idx - 1, end_idx: b.end_idx - 1 };
}
return b;
})
.filter((b): b is NonNullable<typeof b> => b !== null);
commitModules(next, nextBundles);
await nextTick();
playFlipSnapshot(flipSnap);
pushToast(`Removed “${moduleLabel}”`, {
severity: "info",
action: {
label: "Undo",
onSelect: async () => {
const restoreUid = removed._uid;
const scope = modulesContainer.value;
// Re-insert at idx + reconcile so any BundleInstance whose
// start_idx/end_idx sits at or beyond the restored position
// tracks the index shift. Without this, undo of a remove
// that happened above a bundle would leave bundles[] stale
// and `topLevelItems` would slurp the wrong children — the
// same shift bug the drag-drop paths had.
// Resolve insert point via the anchor _uid; fall back to the
// captured idx (clamped). Splicing here works regardless of
// sibling ops that may have shifted modules since remove.
const findAnchor = (): number => {
if (anchorAfter) {
const i = value.value.modules.findIndex((m) => m._uid === anchorAfter);
if (i >= 0) return i;
}
return Math.min(idx, value.value.modules.length);
};
if (restoreUid && scope) {
await withEnterAnimation(restoreUid, scope, () => {
const list = [...value.value.modules];
list.splice(findAnchor(), 0, removed);
// Pass prevBundles so a single-child bundle that dissolved
// when this module left gets re-added by reconcile — the
// restored module's bundle_origin field still points at
// its old BundleInstance _uid, and reconcile matches it
// against the snapshotted entry to rebuild the range.
commitModules(list, prevBundles);
});
} else {
const list = [...value.value.modules];
list.splice(findAnchor(), 0, removed);
commitModules(list, prevBundles);
}
},
},
});
}
async function duplicateModule(idx: number): Promise<void> {
if (idx < 0 || idx >= value.value.modules.length) return;
const flipSnap = captureFlipSnapshot();
const list = [...value.value.modules];
const i = idx;
// Phase B: duplicate is a SIBLING — same uuid + payload_hash, only
// the per-instance binding gets auto-suffixed. FORK (new uuid + new
// library row) lives on Save-to-library when siblings > 1.
const copy: ModuleEntry = JSON.parse(JSON.stringify(list[i]));
// Stamp a fresh `_uid` so the new row has a stable, unique Vue key
// that DOESN'T collide with the source sibling's `_uid`. Without
// this, both rows would share the source's `_uid` after JSON-clone
// and Vue's v-for would warn about duplicate keys.
copy._uid = newRowUid();
const baseBinding = extractPrimaryBinding(copy);
if (baseBinding) {
const taken = collectInContextBindings(list);
const next = nextBindingSuffix(baseBinding, taken);
copy.instance = {
...(copy.instance ?? {}),
variable_binding: next,
};
}
list.splice(i + 1, 0, copy);
// Reconcile bundle ranges — without this, any BundleInstance whose
// `start_idx`/`end_idx` sits at or beyond the insertion point still
// points at its old indices, and the `topLevelItems` walker slurps
// the wrong modules into the bundle (the duplicate gets sucked in,
// the bundle's tail child gets ejected as standalone).
commitModules(list);
await nextTick();
// Sibling rows below the inserted slot shift down via FLIP; the new
// row itself fades + slides in via animateEnterBatch.
playFlipSnapshot(flipSnap);
if (modulesContainer.value && copy._uid) {
await animateEnterBatch([copy._uid], modulesContainer.value);
}
pushToast(`Duplicated "${list[i].meta.name?.trim() || "module"}" as sibling`, {
severity: "success",
lifeMs: 3000,
action: {
label: "Undo",
onSelect: async () => {
const scope = modulesContainer.value;
const dupUid = copy._uid;
// Filter by the duplicate's _uid instead of splicing at the
// captured idx. The captured `i + 1` would point at the wrong
// row if another op (a sibling Undo, a row move, a remove)
// shifted modules between the duplicate and its Undo. _uid is
// stable per instance, so the undo composes with any sibling
// op regardless of click order.
if (dupUid && scope) {
await withLeaveAnimation(dupUid, scope, () => {
commitModules(value.value.modules.filter((m) => m._uid !== dupUid));
});
} else if (dupUid) {
commitModules(value.value.modules.filter((m) => m._uid !== dupUid));
} else {
// No _uid → pre-migration entry. Best-effort: splice at the
// recorded idx, accepting that sibling ops may have shifted
// things. Same risk as before; we degrade rather than fail.
const cur = [...value.value.modules];
cur.splice(i + 1, 1);
commitModules(cur);
}
},
},
});
}
/** Returns the kind-specific primary binding name (without `$`),
* honoring instance overrides. Null when the kind has no single
* primary binding (derivation, constraint, fixed_values). */
function extractPrimaryBinding(m: ModuleEntry): string | null {
const inst = m.instance?.variable_binding;
if (typeof inst === "string" && inst.trim()) return inst.trim();
const p = (m.payload ?? {}) as Record<string, unknown>;
if (m.type === "wildcard") {
const b = p.var_binding;
return typeof b === "string" && b.trim() ? b.trim() : null;
}
if (m.type === "combine") {
const b = p.output_var;
return typeof b === "string" && b.trim() ? b.trim() : null;
}
return null;
}
/** Collect every binding currently in use within this Context's
* modules — used to seed the auto-suffix collision set. */
function collectInContextBindings(modules: readonly ModuleEntry[]): Set<string> {
const out = new Set<string>();
for (const m of modules) {
const b = extractPrimaryBinding(m);
if (b) out.add(b);
if (m.type === "fixed_values") {
for (const e of m.entries ?? []) {
if (e.variable_name?.trim()) out.add(e.variable_name.trim());
}
}
}
return out;
}
function moveToEdge(idx: number, edge: "top" | "bottom") {
const list = [...value.value.modules];
if (idx < 0 || idx >= list.length) return;
const [m] = list.splice(idx, 1);
if (edge === "top") list.unshift(m);
else list.push(m);
// Move-to-edge shifts every module between idx and the new position
// by one slot; bundle ranges follow.
commitModules(list);
// Vue reorders the DOM by detach+reattach; focus is dropped.
// Refocus the moved card by its new position.
const newIdx = edge === "top" ? 0 : list.length - 1;
nextTick(() => {
const el = document.querySelector<HTMLElement>(`.wp-module[data-module-idx="${newIdx}"]`);
el?.focus();
});
}
function toggleEnabled(idx: number) {
if (idx < 0 || idx >= value.value.modules.length) return;
const list = [...value.value.modules];
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
commitModules(list);
}
function toggleCollapsed(idx: number) {
const target = value.value.modules[idx];
if (!target) return;
// Phase B: index-keyed so sibling rows (same uuid) collapse
// independently. Pre-Phase-B map-by-id flipped every sibling at once.
const willExpand = target.collapsed === true;
const accordion = getCollapseMode() === "accordion" && willExpand;
const list = value.value.modules.map((m, i) => {
if (i === idx) return { ...m, collapsed: !m.collapsed };
if (accordion) return { ...m, collapsed: true };
return m;
});
commitModules(list);
}
/** Bulk collapse/expand. Used by the section-header chevron — one
* click flips every card to the same state. Idempotent if all
* cards already match the target state (the deep watcher will
* diff-eq and skip the onChange emit). */
function setAllCollapsed(collapsed: boolean) {
const nextModules = value.value.modules.map((m) => ({ ...m, collapsed }));
// Bundle FRAMES carry their own `collapsed` flag (separate from the child
// module cards). Collapse/expand-all flips both so the action visibly folds
// bundle frames, not just loose modules.
const nextBundles = (value.value.bundles ?? []).map((b) => ({ ...b, collapsed }));
commitModules(nextModules, nextBundles);
}
/** Toolbar counts — total modules + how many are effectively enabled
* (each child's own `enabled` AND the bundle gate, when bundled). */
const totalCount = computed(() => value.value.modules.length);
const enabledCount = computed(() => {
const bundleEnabled = buildBundleEnabledMap(value.value.bundles);
return value.value.modules.filter((m) => isModuleEffectivelyEnabled(m, bundleEnabled)).length;
});
// ── Node codename chip ──────────────────────────────────────────────────────
// A fixed, read-only CODENAME identifies THIS WP_Context node: a stable
// `adjective-noun` derived from the litegraph node id (node-codename.ts).
// POV-independent + unique on the canvas, and NOT user-editable — cross-node
// UI (constraint reach pick-list, pair popovers) shows the SAME codename to
// name WHICH node a target instance lives in. Replaced the old editable label
// + walk-position A/B/C letter, which shifted by viewer (so two chain heads
// both showed "A") and was editable (so not a stable identifier).
const nodeCodename = computed<string>(() => baseCodename(props.nodeId));
/** Toolbar bulk actions — wrappers so the toolbar template stays compact. */
function collapseAll(): void { setAllCollapsed(true); }
function expandAll(): void { setAllCollapsed(false); }
function toggleAllEnabled(): void {
// Operate at the TOP LEVEL only. A standalone module is one unit (its own
// `enabled`); a bundle is one unit (its MASTER `enabled` gate). Children
// inside a bundle are governed by the master (effective = master AND
// child), so toggle-all NEVER reaches in to flip them — disabling a bundle
// goes through its master, leaving each child's own `enabled` intact for a
// restore-safe re-enable. Nested bundles (parent_uid set) follow their
// parent master via the AND chain, so only top-level masters are flipped.
const isChild = (m: ModuleEntry): boolean =>
!!(m as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
const bundles = value.value.bundles ?? [];
const topModules = value.value.modules.filter((m) => !isChild(m));
const topBundles = bundles.filter((b) => !b.parent_uid);
// Direction: if ANY top-level unit is on, turn everything off; else on.
const anyOn =
topModules.some((m) => m.enabled !== false)
|| topBundles.some((b) => b.enabled !== false);
const next = !anyOn;
const nextModules = value.value.modules.map((m) =>
isChild(m) ? m : { ...m, enabled: next },
);
const nextBundles = bundles.map((b) => (b.parent_uid ? b : { ...b, enabled: next }));
commitModules(nextModules, nextBundles);
}
/** Open the SPA dashboard in a new tab. Mirrors `extension/topbar.ts`'s
* `openSpa` so widget + topbar both land on the dashboard overview
* rather than the wildcards list (which is what the SPA root redirect
* resolves to). */
function openSpaLibrary(): void {
window.open("/wp/dashboard", "_blank", "noopener");
}
/**
* Edit opens `ModuleEditModal` for every kind. The modal renders a
* kind-aware body:
* - `fixed_values` → inline name/value entry editor (legacy flow).
* - everything else → snapshot-instance preview with name +
* description editing + a deep-link to the full SPA editor.
*
* Snapshot edits stay local to this workflow — the underlying
* library row is not touched. Major payload edits (option weights,
* ref bindings, derivation rules, …) live in the SPA editor.
*/
function openEditModal(idx: number) {
if (idx < 0 || idx >= value.value.modules.length) return;
editingIdx.value = idx;
}
function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
// Phase B: index-based replacement — sibling rows share `m.id`, so
// mapping `m.id === updated.id` would clobber every sibling at once.
// We use `editingIdx` captured at modal-open to target the specific
// row. `_originalId` survives from the fork path (id changed during
// save) to drive the post-fork green flash.
const targetIdx = editingIdx.value;
if (targetIdx == null || targetIdx < 0 || targetIdx >= value.value.modules.length) {
editingIdx.value = null;
return;
}
const cleaned: ModuleEntry = { ...updated };
delete (cleaned as { _originalId?: string })._originalId;
const list = [...value.value.modules];
list[targetIdx] = cleaned;
commitModules(list);
if (updated._originalId && updated._originalId !== updated.id) {
nextTick(() => {
const el = document.querySelector<HTMLElement>(
`[data-module-idx="${targetIdx}"]`,
);
if (el) {
el.classList.add("wp-module--flash");
setTimeout(() => el.classList.remove("wp-module--flash"), 420);
}
});
}
editingIdx.value = null;
}
/**
* Open the BundleInstanceModal for `uid`. Snapshots the live
* BundleInstance into `bundleDraft` so the user's typing in
* IdentitySection doesn't commit until Save. Kicks off a one-shot
* library fetch in parallel so the "reset to library default" per-
* field buttons can light up once the canonical name/color land.
*
* Lock / Hide master toggles deliberately bypass the draft — they
* cascade onto live children, so buffering them through the modal
* would diverge from the canvas state. Same handlers
* `BundleHeader` uses.
*/
function openBundleEditModal(uid: string): void {
const target = (value.value.bundles ?? []).find((b) => b._uid === uid);
if (!target) return;
editingBundleUid.value = uid;
bundleDraft.value = JSON.parse(JSON.stringify(target));
bundleLibraryDefaults.value = null;
// Fire-and-forget: when the fetch lands, IdentitySection's per-
// field reset surfaces. Modal stays usable in the interim — user
// can edit name/color even before the library entry resolves.
void (async () => {
try {
const entry = await api.bundles.get(target.library_id);
// Modal may have closed mid-fetch; bail if so.
if (editingBundleUid.value !== uid) return;
bundleLibraryDefaults.value = {
name: entry.name,
color: entry.color ?? null,
};
} catch {
// Library entry deleted / network error — leave defaults null
// so the per-field reset buttons stay hidden, but the user
// can still edit name/color directly. No toast: this isn't
// an error from the user's perspective.
}
})();
}
function onBundleEditUpdate(patch: Partial<BundleInstance>): void {
if (!bundleDraft.value) return;
bundleDraft.value = { ...bundleDraft.value, ...patch };
}
function saveBundleEdit(): void {
const uid = editingBundleUid.value;
const draft = bundleDraft.value;
if (!uid || !draft) {
editingBundleUid.value = null;
bundleDraft.value = null;
return;
}
const bundles = value.value.bundles ?? [];
const idx = bundles.findIndex((b) => b._uid === uid);
if (idx < 0) {
editingBundleUid.value = null;
bundleDraft.value = null;
return;
}
// Only identity fields flow through the draft — preserve every
// other field (range, hash, fingerprint, …) from the live bundle
// so cascading toggles that fired during the modal session aren't
// overwritten.
const live = bundles[idx];
const next: BundleInstance = {
...live,
name: draft.name,
color: draft.color ?? null,
};
const nextBundles = [...bundles];
nextBundles[idx] = next;
value.value = { ...value.value, bundles: nextBundles };
editingBundleUid.value = null;
bundleDraft.value = null;
bundleLibraryDefaults.value = null;
}
function cancelBundleEdit(): void {
editingBundleUid.value = null;
bundleDraft.value = null;
bundleLibraryDefaults.value = null;
}
/** Wrap the existing ctx-level toggle so the modal's runtime
* buttons trigger the same cascade as the BundleHeader / ctxmenu.
* Live mutation — no draft involvement. */
function onBundleEditToggleLock(): void {
const uid = editingBundleUid.value;
if (!uid) return;
toggleBundleLock(uid);
}
function onBundleEditToggleInternal(): void {
const uid = editingBundleUid.value;
if (!uid) return;
toggleBundleInternal(uid);
}
function onBundleEditSaveToLibrary(): void {
const uid = editingBundleUid.value;
if (!uid) return;
// Commit the draft FIRST so the user's pending name / color edits
// land on the live BundleInstance, then open the push-to-library
// modal for explicit Update / Save as new selection.
saveBundleEdit();
openPushBundleToLibrary(uid);
}
function onBundleEditResetToLibrary(): void {
const uid = editingBundleUid.value;
if (!uid) return;
cancelBundleEdit();
void resetBundleToLibrary(uid);
}
function onCardKeydown(ev: KeyboardEvent, m: ModuleEntry, idx: number) {
// Don't intercept keys when focus is inside an input/textarea inside the
// card (none today, but defense in depth for future inline controls).
const target = ev.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
void m; // module ref retained for future kind-aware shortcuts
// Ctrl/Cmd+D — duplicate focused module
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "d") {
ev.preventDefault();
duplicateModule(idx);
return;
}
// Ctrl/Cmd+Shift+ArrowUp / ArrowDown — jump to edge of list.
if ((ev.ctrlKey || ev.metaKey) && ev.shiftKey && ev.key === "ArrowUp") {
ev.preventDefault();
moveToEdge(idx, "top");
return;
}
if ((ev.ctrlKey || ev.metaKey) && ev.shiftKey && ev.key === "ArrowDown") {
ev.preventDefault();
moveToEdge(idx, "bottom");
return;
}
// Shift+ArrowUp / Shift+ArrowDown — reorder ±1
if (ev.shiftKey && ev.key === "ArrowUp") {
ev.preventDefault();
moveModule(idx, -1);
return;
}
if (ev.shiftKey && ev.key === "ArrowDown") {
ev.preventDefault();
moveModule(idx, 1);
return;
}
// Enter — open edit modal (matches the context menu's primary action)
if (ev.key === "Enter") {
ev.preventDefault();
openEditModal(idx);
return;
}
// Delete — remove module
if (ev.key === "Delete") {
ev.preventDefault();
removeModule(idx);
return;
}
}
function moveModule(idx: number, dir: -1 | 1) {
const list = [...value.value.modules];
const j = idx + dir;
if (idx < 0 || idx >= list.length || j < 0 || j >= list.length) return;
[list[idx], list[j]] = [list[j], list[idx]];
// Reconcile bundle ranges: when the swapped pair straddles a bundle
// boundary, the bundle's children shift by one slot and the start/
// end indices have to follow.
commitModules(list);
// Vue reorders the DOM by detach+reattach even with :key; focus is dropped.
// Refocus the moved card by its new idx (composite key includes idx).
nextTick(() => {
const el = document.querySelector<HTMLElement>(`.wp-module[data-module-idx="${j}"]`);
el?.focus();
});
}
function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
// Phase B: caller passes the row's array index (from v-for) so we
// operate on the specific sibling rather than the first findIndex
// match — siblings share `m.id`.
const list = value.value.modules;
const i = idx;
const estW = 180;
const estH = 220;
const x = Math.min(ev.clientX, window.innerWidth - estW - 8);
const y = Math.min(ev.clientY, window.innerHeight - estH - 8);
// Build the items list dynamically — "Save to library" only shows
// when the module is missing from the live library AND has a
// payload to save (inline-created fixed_values without a payload
// are intentionally excluded; they're already authoritative on the
// workflow side).
const items: ContextMenuItem[] = [
{ label: "Edit", icon: "pi-pencil", onSelect: () => openEditModal(idx) },
];
// Refresh + Save are mutually exclusive in normal use (a module is
// either drifted OR missing OR clean), so hiding the inactive entry
// beats greying it — matches Save's existing conditional-push pattern
// and keeps the menu shorter.
if (isDrifted(m)) {
items.push({
label: "Refresh from library",
icon: "pi-refresh",
onSelect: () => { void refreshOne(idx); },
divider: true,
});
}
// "Push to library" is now always present for any row that has a
// payload. The unified PushToLibraryModal owns the explicit fork-vs-
// update choice + the meta editing surface, replacing the older
// implicit logic that only surfaced when the row was missing from
// the live library. Inline-created rows still qualify — the modal
// greys out the "Update existing" button when payload_hash is empty.
if (!!m.payload) {
const missing = isMissingFromLibrary(m);
items.push({
label: "Push to library…",
icon: "pi-cloud-upload",
subtitle: missing ? "Library entry deleted — re-add as new entry" : undefined,
accent: missing,
onSelect: () => openPushToLibrary(idx),
divider: true,
});
}
// Bundle children get an extra "Reset to bundle snapshot" item that
// restores THIS row from the parent bundle's frozen library blob.
// Distinct from "Refresh from library" (which pulls the current
// library version of the underlying module).
if ((m as ModuleEntry & { bundle_origin?: string }).bundle_origin) {
items.push({
label: "Reset to bundle snapshot",
icon: "pi-history",
subtitle: "Restore frozen state from bundle",
onSelect: () => { void resetChildToBundleSnapshot(idx); },
divider: true,
});
}
items.push(
{ label: m.enabled ? "Disable" : "Enable", icon: m.enabled ? "pi-eye-slash" : "pi-eye", onSelect: () => toggleEnabled(idx) },
{ label: m.collapsed ? "Expand" : "Collapse", icon: m.collapsed ? "pi-caret-down" : "pi-caret-right", onSelect: () => toggleCollapsed(idx) },
{ label: "Duplicate", icon: "pi-clone", onSelect: () => duplicateModule(idx), divider: true },
);
// Wrap-into-new-bundle — only for unbundled rows.
if (!(m as ModuleEntry & { bundle_origin?: string }).bundle_origin) {
items.push({
label: "Wrap into new bundle…",
icon: "pi-box",
subtitle: "Create a library bundle from this row",
onSelect: () => { void wrapIntoNewBundle(idx); },
divider: true,
});
}
items.push(
{ label: "Move to top", icon: "pi-angle-double-up", disabled: i === 0, onSelect: () => moveToEdge(idx, "top") },
{ label: "Move to bottom", icon: "pi-angle-double-down", disabled: i === list.length - 1, onSelect: () => moveToEdge(idx, "bottom") },
{ label: "Remove", icon: "pi-trash", danger: true, divider: true, onSelect: () => removeModule(idx) },
);
ctxMenu.value = {
visible: true, x: Math.max(8, x), y: Math.max(8, y), items,
// Scope header — kind icon + "Kind · Name" label. Mirrors the
// bundle ctxmenu's header so every right-click reads as "you are
// operating on THIS specific entity". `kindIcon` returns
// "pi pi-X" (two-class); ContextMenu's template prepends "pi"
// already, so we strip the leading word to avoid double-prefix.
header: {
icon: kindIcon(m.type).split(" ").pop() ?? "pi-circle",
label: `${KIND_TITLE[m.type] ?? m.type} · ${m.meta.name || "(unnamed)"}`,
iconColor: `var(--wp-kind-${kindChipModifier(m.type)})`,
},
};
}
// ── Drag-and-drop ───────────────────────────────────────────────────────
function onDragStart(ev: DragEvent, mod: ModuleEntry, idx: number) {
// sourceIdx disambiguates siblings sharing `mod.id` at drop time.
dragState.value = {
kind: "module",
sourceNodeId: props.nodeId,
module: JSON.parse(JSON.stringify(mod)),
sourceIdx: idx,
sourceBundleUid: (mod as ModuleEntry & { bundle_origin?: string }).bundle_origin ?? null,
};
draggingModuleUid.value = mod._uid ?? null;
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", mod.id);
}
}
// Pulses the just-dropped module(s) so the user sees where they landed.
// Accepts a single uid OR an array — cross-node bundle drops pass every
// freshly-inserted child so all rows pulse (Phase B.4 bug fix).
//
// Pulse start is delayed by MOTION_FLIP_MS so the row has finished its
// arriving fade-slide before the box-shadow ring fires. Without the
// delay the pulse started mid-fade and the ring painted on a row whose
// opacity was still transitioning — read as 'pulse before row arrived'.
let dropPulseStartTimer: number | null = null;
function pulseDrop(uids: string | string[] | undefined | null): void {
if (!uids) return;
const list = (Array.isArray(uids) ? uids : [uids]).filter((u): u is string => !!u);
if (list.length === 0) return;
if (dropPulseTimer != null) window.clearTimeout(dropPulseTimer);
if (dropPulseStartTimer != null) window.clearTimeout(dropPulseStartTimer);
dropPulseStartTimer = window.setTimeout(() => {
recentDropUids.value = new Set(list);
pulseOrder.value = new Map(list.map((uid, i) => [uid, i]));
dropPulseStartTimer = null;
// Last pulse must complete: stagger * (n-1) + pulse duration + 50ms buffer.
const totalMs = 420 + (list.length - 1) * 60 + 50;
dropPulseTimer = window.setTimeout(() => {
recentDropUids.value = new Set();
pulseOrder.value = new Map();
dropPulseTimer = null;
}, totalMs);
}, MOTION_FLIP_MS);
}
// Per-row CSS animation-delay derived from pulse order. Returns "0ms"
// when uid isn't in the current pulse batch — falls through harmlessly
// because no .wp-drop-pulse class is applied either.
function pulseDelayFor(uid: string | null | undefined): string {
if (!uid) return "0ms";
const idx = pulseOrder.value.get(uid);
return idx == null ? "0ms" : `${idx * 60}ms`;
}
// Bundle-as-unit drag — header drag handle initiates a "bundle" payload
// carrying the bundle's uid + pre-drag range. Receiver re-slices the
// range out and re-inserts at the resolved zone.
function onBundleDragStart(ev: DragEvent, uid: string) {
const b = (value.value.bundles ?? []).find((bb) => bb._uid === uid);
if (!b) return;
// Snapshot children so cross-node drop can splice them into a
// different Context. Deep clone via JSON to detach from the source
// node's reactive state. Children carry their ORIGINAL bundle_origin
// (which may be outer-uid OR inner-uid) so the receiver can remap
// the nesting chain after minting fresh uids.
const children = value.value.modules
.slice(b.start_idx, b.end_idx + 1)
.map((m) => JSON.parse(JSON.stringify(m)) as ModuleEntry);
// Capture inner BundleInstances (parent_uid === outer). Without
// this, cross-node drop loses the nesting — receiver only mints
// the outer and stamps every child with outer.uid, flattening any
// inner bundle structure. Deep-clone to detach from source state.
const innerInstances = (value.value.bundles ?? [])
.filter((bb) => bb.parent_uid === uid)
.map((bb) => JSON.parse(JSON.stringify(bb)) as BundleInstance);
dragState.value = {
kind: "bundle",
sourceNodeId: props.nodeId,
bundleUid: uid,
sourceStartIdx: b.start_idx,
sourceEndIdx: b.end_idx,
libraryId: b.library_id,
bundleName: b.name,
bundleColor: b.color ?? null,
bundleCollapsed: b.collapsed,
bundleEnabled: b.enabled,
children,
innerInstances,
};
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", `bundle:${uid}`);
}
}
function onDragEnd() {
const ds = dragState.value;
if (ds && ds.sourceNodeId === props.nodeId && !sameNodeDropHandled) {
const consumedByOther = ds.consumedBy != null && ds.consumedBy !== props.nodeId;
if (consumedByOther) {
if (ds.kind === "module") {
// Cross-node consumption — filter by `_uid`, NOT by sourceIdx.
// The handoff queue watch may have already removed the row
// before this dragend fires (Vue's watch flush ordering vs
// browser-dispatched dragend isn't deterministic). Splicing
// at the recorded sourceIdx would then delete the row that
// shifted into that slot — observed as "drag deleted my
// target wildcard" when the constraint sat next to its
// target. Filtering by _uid is idempotent: if the row's
// already gone, the filter is a no-op. `m.id` is the library
// uuid shared by siblings, so it's only a fallback for
// pre-_uid migration entries.
const dragUid = ds.module._uid;
const curBundles = value.value.bundles ?? [];
if (dragUid) {
const filtered = value.value.modules.filter((m) => m._uid !== dragUid);
if (filtered.length !== value.value.modules.length) {
commitModules(filtered, curBundles);
}
} else {
// No _uid — only happens for ancient saved workflows that
// predate the per-row uid stamping pass. Best-effort splice
// by sourceIdx, accepting the risk of removing the wrong
// row if anything shifted it.
const srcIdx = ds.sourceIdx;
if (srcIdx >= 0 && srcIdx < value.value.modules.length) {
const list = [...value.value.modules];
list.splice(srcIdx, 1);
commitModules(list, curBundles);
} else {
const filtered = value.value.modules.filter((m) => m.id !== ds.module.id);
commitModules(filtered, curBundles);
}
}
} else if (ds.kind === "bundle") {
// Cross-node bundle drop — same idempotency story as the
// module path. Filter the bundle range by its bundleUid +
// descendants instead of relying on stored start_idx/end_idx
// that the handoff queue may have already invalidated.
const movingBundleUids = new Set<string>([ds.bundleUid]);
// First pass: collect every inner whose parent is in the set.
// Repeated until no new uids land — covers nested bundles
// even though the tier-2 cap means one inner level today.
const curBundles = value.value.bundles ?? [];
let grew = true;
while (grew) {
grew = false;
for (const b of curBundles) {
if (b.parent_uid && movingBundleUids.has(b.parent_uid) && !movingBundleUids.has(b._uid)) {
movingBundleUids.add(b._uid);
grew = true;
}
}
}
const bundleStillPresent = curBundles.some((b) => b._uid === ds.bundleUid);
if (!bundleStillPresent) {
// Handoff already cleaned up; nothing left to do.
} else {
const movingBundle = curBundles.find((b) => b._uid === ds.bundleUid);
if (movingBundle) {
const start = movingBundle.start_idx;
const end = movingBundle.end_idx;
const before = value.value.modules.slice(0, start);
const after = value.value.modules.slice(end + 1);
const removedCount = end - start + 1;
const remainingBundles = curBundles
.filter((b) => !movingBundleUids.has(b._uid))
.map((b) => (b.start_idx > end
? { ...b, start_idx: b.start_idx - removedCount, end_idx: b.end_idx - removedCount }
: b));
commitModules([...before, ...after], remainingBundles);
}
}
}
}
}
dragState.value = null;
sameNodeDropHandled = false;
dragOver.value = null;
draggingModuleUid.value = null;
}
let sameNodeDropHandled = false;
// Dragover on a row: standalone rows produce row zones (before/after);
// any bundle-member row resolves to its bundle's "inside" zone — the
// frame highlights as a single drop target. "before-bundle" comes from
// the bundle header (dragover handler on BundleHeader); "after-bundle"
// comes from the row immediately after the bundle range (treated as a
// row "before" drop on the post-bundle row).
// List-level dragover resolver — walks every top-level item (rows +
/** Dragover handler — delegates pointer-to-zone classification to the
* pure resolver. The resolver walks DOM from the pointer (innermost
* `.wp-bundle` ancestor wins as the target container) and applies the
* tier-2 cap. ContextWidget just sets `dragOver.value`; the indicator
* paint happens via the per-container `<BundleDropBar>` reading
* `dropBarFor()`. */
function onListDragOver(ev: DragEvent) {
const ds = dragState.value;
if (!ds) return;
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
const container = modulesContainer.value;
if (!container) return;
dragOver.value = resolveDropZone(ev, container, value.value, ds);
}
// Empty-state hero is the drop target when the node has no modules.
// Cross-node drag onto an empty node lands here.
function onEmptyHeroDragOver(ev: DragEvent) {
const ds = dragState.value;
if (!ds) return;
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
dragOver.value = { kind: "slot", containerUid: null, insertIdx: 0 };
}
function onEndDragOver(ev: DragEvent) {
if (!dragState.value) return;
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
dragOver.value = {
kind: "slot",
containerUid: null,
insertIdx: value.value.modules.length,
};
}
function onContainerLeave(ev: DragEvent) {
const container = ev.currentTarget as HTMLElement;
const next = ev.relatedTarget as Node | null;
if (next && container.contains(next)) return;
clearDragHover();
}
async function onDrop(ev: DragEvent, targetIdx: number | null) {
ev.preventDefault();
ev.stopPropagation();
const ds = dragState.value;
if (!ds) return;
// Sync claim BEFORE any `await`. Browsers fire `dragend` on the
// source element as soon as the drop handler returns its first
// Promise (the `await nextTick()` below), and the source widget's
// dragend reads `dragState.value.consumedBy` synchronously to decide
// whether to splice out the moved row. Without this claim, the
// source widget always reads `null` (we set consumedBy at the END
// of the cross-node paths, well past several awaits) and the
// cross-node MOVE silently becomes a COPY — the row stays in both
// places. Skip the claim when the cross-node module path would
// dedupe-reject; otherwise we'd tell the source to remove its row
// even though the target rejected the drop.
const isCrossNode = ds.sourceNodeId !== props.nodeId;
if (isCrossNode) {
if (ds.kind === "module") {
const willDedupeBlock = value.value.modules.some((m) => m.id === ds.module.id);
if (!willDedupeBlock) {
dragState.value = { ...ds, consumedBy: props.nodeId };
// Belt-and-suspenders: queue an explicit handoff for the
// source widget. dragend on the source is the primary cleanup
// path, but has been observed to miss intermittently for
// constraint rows whose containing bundle frame re-renders
// during the drag tick — the dragend bubbles before the row
// re-mounts, then the consumedBy read races with the source
// widget's reactive update. Queue ensures the source's
// post-drop watch removes the row even if dragend skipped it.
if (ds.module._uid) {
queueHandoff({
kind: "module",
sourceNodeId: ds.sourceNodeId,
uid: ds.module._uid,
});
}
}
} else if (ds.kind === "bundle") {
// Bundle cross-node drops always accept (no dedupe gate on the
// bundle branch); claim eagerly.
dragState.value = { ...ds, consumedBy: props.nodeId };
queueHandoff({
kind: "bundle",
sourceNodeId: ds.sourceNodeId,
bundleUid: ds.bundleUid,
sourceStartIdx: ds.sourceStartIdx,
sourceEndIdx: ds.sourceEndIdx,
});
}
}
// Snapshot zone before clearing hover state. Null target idx + null
// zone → slot at end of top-level (empty hero / sticky footer drop).
const zone: DropZone =
dragOver.value ??
(targetIdx === null
? { kind: "slot", containerUid: null, insertIdx: value.value.modules.length }
: null);
dragOver.value = null;
// Wait for Vue to flush the dragOver=null update so the wp-gap-before
// / wp-gap-after class is removed from the anchor row/bundle BEFORE
// we capture rects. Otherwise the captured rect carries the 14px
// gap margin → post-Vue rect doesn't → FLIP animates a phantom
// 14px shift on the anchor and its siblings (user-visible as
// "other modules animate when swapping children").
await nextTick();
// Capture pre-mutation rects for FLIP-move. Captures top-level + every
// in-bundle child container so any reorder animates its scope after
// Vue commits + re-renders.
const flipSnap = captureFlipSnapshot();
// Same-node module drop — applyDrop covers the full reorder +
// bundle_origin rewriting in one pure step.
if (ds.kind === "module" && ds.sourceNodeId === props.nodeId) {
const dragUid = ds.module._uid;
// Fallback resolution when sourceIdx was invalidated mid-drag.
// Prefer matching the per-instance _uid so the right sibling moves;
// matching `m.id` (library uuid) would hit the FIRST sibling.
const list = value.value.modules;
const fromIdx = ds.sourceIdx >= 0 && ds.sourceIdx < list.length
? ds.sourceIdx
: dragUid
? list.findIndex((mm) => mm._uid === dragUid)
: list.findIndex((mm) => mm.id === ds.module.id);
if (fromIdx < 0) return;
const payload: DropPayload = { kind: "module", sourceIdx: fromIdx, sourceUid: dragUid ?? "" };
const next = applyDrop(zone, payload, value.value);
// Cross-scope detection for the FLIP/arriving animation seam.
const movedRow = next.modules.find((mm) => mm._uid === dragUid);
const newOrigin = (movedRow as ModuleEntry & { bundle_origin?: string } | undefined)?.bundle_origin ?? null;
const crossScope = (ds.sourceBundleUid ?? null) !== newOrigin;
if (crossScope && dragUid) excludeFromFlipSnapshot(flipSnap, dragUid);
// Auto-expand the destination bundle when a row drops INTO it —
// without this the child lands invisibly under a collapsed frame.
const finalBundles = (next.bundles ?? []).map((b) =>
newOrigin && b._uid === newOrigin && b.collapsed ? { ...b, collapsed: false } : b,
);
commitModules(next.modules, finalBundles);
await nextTick();
playFlipSnapshot(flipSnap);
if (crossScope && dragUid && modulesContainer.value) {
await animateEnterBatch([dragUid], modulesContainer.value);
}
pulseDrop(dragUid ?? undefined);
sameNodeDropHandled = true;
return;
}
// Same-node bundle move — applyDrop slices the range out + re-inserts
// at the resolved position + rewrites parent_uid.
if (ds.kind === "bundle" && ds.sourceNodeId === props.nodeId) {
const payload: DropPayload = {
kind: "bundle",
bundleUid: ds.bundleUid,
sourceStartIdx: ds.sourceStartIdx,
sourceEndIdx: ds.sourceEndIdx,
};
const next = applyDrop(zone, payload, value.value);
// No-op when applyDrop returns the same value object (self-drop
// guard inside applyDrop fires for header on own uid). Still
// clear drag state explicitly — browser's dragend may not fire
// reliably after a bundle's DOM gets re-keyed by the patch, and
// a lingering dragState keeps the "Drop here" affordance visible.
if (next === value.value) {
sameNodeDropHandled = true;
dragState.value = null;
return;
}
holdSuppressMove();
commitModules(next.modules, next.bundles);
await nextTick();
playFlipSnapshot(flipSnap);
// Pulse the bundle wrapper AND every row in its range so a
// collapsed bundle still shows a landing flash.
const rangeRows = next.modules.slice(
(next.bundles ?? []).find((b) => b._uid === ds.bundleUid)?.start_idx ?? 0,
((next.bundles ?? []).find((b) => b._uid === ds.bundleUid)?.end_idx ?? -1) + 1,
);
pulseDrop([ds.bundleUid, ...rangeRows.map((r) => r._uid).filter((u): u is string => !!u)]);
sameNodeDropHandled = true;
// Belt-and-suspenders: clear drag state here too. The bundle
// frame's dragend listener may not fire if Vue's reconciler
// detaches the source frame during the same-tick patch.
dragState.value = null;
return;
}
// Cross-node bundle drag. Receiver splices the bundle's children
// (with fresh _uid + remapped bundle_origin) into its modules array
// at the zone-resolved position, and registers a fresh BundleInstance
// for the outer + every inner. Source-uid → fresh-uid map preserves
// the nesting chain across the cross-node trip. The resolver enforces
// tier-2 cap, so the dropped outer's parent_uid stays null on
// cross-node lands.
if (ds.kind === "bundle") {
const list = [...value.value.modules];
const bundles = value.value.bundles ?? [];
const { insertIdx } = resolveCrossNodeInsertion(zone, list, bundles);
// Receiver's drop container becomes newBundle's parent_uid when
// dropping INSIDE another bundle's body. Without this, the
// newBundle is top-level while its children sit inside the
// receiver bundle's range — reconcile sees non-contiguous indices
// on the receiver and dissolves it.
const outerParentUid = zone?.kind === "slot" ? zone.containerUid : null;
const { newBundle, freshInners, newChildren } = buildCrossNodeBundleInsertion(
{
bundleUid: ds.bundleUid,
libraryId: ds.libraryId,
bundleName: ds.bundleName,
bundleColor: ds.bundleColor,
bundleCollapsed: ds.bundleCollapsed,
bundleEnabled: ds.bundleEnabled,
children: ds.children,
innerInstances: ds.innerInstances,
},
insertIdx,
outerParentUid,
newRowUid,
emptyBundleInstance,
);
list.splice(insertIdx, 0, ...newChildren);
// reconcileBundleRanges (via commitModules) will recompute
// start_idx/end_idx for every bundle from each child's bundle_origin
// chain — the placeholder ranges on freshInners get overwritten.
commitModules(list, [...bundles, newBundle, ...freshInners]);
// Snap fingerprints on the freshly-cloned outer + inners so the
// cross-node receive registers as a clean baseline (matches the
// semantics of a library insert).
{
const justReceived = new Set<string>([
newBundle._uid,
...freshInners.map((b) => b._uid),
]);
value.value = {
...value.value,
bundles: snapBundleFingerprints(value.value.bundles ?? [], value.value.modules, justReceived),
};
}
await nextTick();
playFlipSnapshot(flipSnap);
pulseDrop([
newBundle._uid,
...freshInners.map((b) => b._uid),
...newChildren.map((c) => c._uid).filter((u): u is string => !!u),
]);
// consumedBy was claimed synchronously at the top of onDrop — see
// the comment there. Re-assigning here would resurrect dragState
// after the source widget's dragend cleared it, leaving a stale
// payload visible to dragOver handlers until the next dragstart.
return;
}
// Cross-node MODULE drop. Same dedupe rule as before.
if (value.value.modules.some((m) => m.id === ds.module.id)) {
const dupName = ds.module.meta?.name?.trim() || ds.module.type;
pushToast(
`"${dupName}" is already in this node. Use right-click → Duplicate to add another instance.`,
{ severity: "error" },
);
const existingEl = document.querySelector<HTMLElement>(
`[data-module-id="${ds.module.id}"]`,
);
if (existingEl) {
existingEl.classList.add("wp-module--shake");
setTimeout(() => existingEl.classList.remove("wp-module--shake"), 220);
}
sameNodeDropHandled = true;
return;
}
const isLibraryBacked = ds.module.type !== "fixed_values"
|| (ds.module.payload !== undefined && Object.keys(ds.module.payload ?? {}).length > 0);
const baseInsert: ModuleEntry = isLibraryBacked
? { ...ds.module, _uid: newRowUid() }
: { ...ds.module, id: newModuleId(), _uid: newRowUid() };
const list = [...value.value.modules];
const bundles = value.value.bundles ?? [];
const { insertIdx, stamp } = resolveCrossNodeInsertion(zone, list, bundles);
const inserted = { ...baseInsert } as ModuleEntry & { bundle_origin?: string };
if (stamp) inserted.bundle_origin = stamp;
else delete inserted.bundle_origin;
list.splice(insertIdx, 0, inserted);
const preBundles = bundles.map((b) =>
stamp && b._uid === stamp && b.collapsed ? { ...b, collapsed: false } : b,
);
commitModules(list, preBundles);
await nextTick();
playFlipSnapshot(flipSnap);
pulseDrop(inserted._uid);
// consumedBy was claimed synchronously at the top of onDrop. See
// the comment there for why we don't reassign post-await.
}
/** Map the slot DropZone to `{insertIdx, stamp}` for the cross-node
* insertion paths (where we're adding a NEW row/bundle to this widget
* rather than moving an existing one). Trivial under the slot model:
* insertIdx + containerUid go straight through. */
function resolveCrossNodeInsertion(
zone: DropZone,
modules: ModuleEntry[],
_bundles: BundleInstance[],
): { insertIdx: number; stamp: string | undefined } {
if (!zone) return { insertIdx: modules.length, stamp: undefined };
return { insertIdx: zone.insertIdx, stamp: zone.containerUid ?? undefined };
}
// Top-level render list: standalone modules + bundle wrappers, in
// document order. Each bundle entry packs its child rows so the
// template can iterate once + nest children inside a real .wp-bundle
// div (no more absolute-positioned overlay).
//
// Tier-2 nesting: a bundle's `children[]` may include a NESTED bundle
// wrapper (an inner BundleInstance whose parent_uid points at the
// outer). The renderer recurses one level — the API cap forbids
// deeper chains so a single `BundleChild` recursion is enough.
type BundleChild =
| { kind: "mod"; key: string; module: ModuleEntry; idx: number }
| { kind: "bundle"; key: string; bundle: BundleInstance; children: BundleChild[] };
interface TopLevelItem {
kind: "mod" | "bundle";
key: string;
module?: ModuleEntry;
idx?: number;
bundle?: BundleInstance;
children?: BundleChild[];
}
const topLevelItems = computed<TopLevelItem[]>(() => {
const modules = value.value.modules;
const bundles = value.value.bundles ?? [];
// Group inner bundles by parent_uid for O(1) lookup during the walk.
const innersByParent = new Map<string, BundleInstance[]>();
for (const b of bundles) {
const parent = typeof b.parent_uid === "string" ? b.parent_uid : null;
if (!parent) continue;
const list = innersByParent.get(parent) ?? [];
list.push(b);
innersByParent.set(parent, list);
}
// Build the child list for a bundle range [start, end]. Walks the
// module positions; when a position matches an inner bundle's
// start_idx, emits a nested bundle wrapper covering that inner's
// span. Other positions emit leaf module sub-items.
function bundleChildren(parentUid: string, start: number, end: number): BundleChild[] {
const inners = (innersByParent.get(parentUid) ?? []).slice().sort(
(a, b) => a.start_idx - b.start_idx,
);
const out: BundleChild[] = [];
let j = start;
while (j <= end) {
const inner = inners.find((ii) => ii.start_idx === j && ii.end_idx >= ii.start_idx);
if (inner) {
// Inner bundle: recurse into its grandchildren. Tier-2 cap →
// the recursive call will find no further inners under this
// inner, but the structure stays uniform for the template.
out.push({
kind: "bundle",
key: `b-${inner._uid}`,
bundle: inner,
children: bundleChildren(inner._uid, inner.start_idx, inner.end_idx),
});
j = inner.end_idx + 1;
continue;
}
const m = modules[j];
if (m) {
out.push({ kind: "mod", key: m._uid ?? `m-${j}`, module: m, idx: j });
}
j++;
}
return out;
}
const out: TopLevelItem[] = [];
let i = 0;
while (i < modules.length) {
// Top-level bundles only — bundles with a parent_uid render inside
// their parent's children list via bundleChildren, not at the top.
const b = bundles.find((bb) => bb.start_idx === i && !bb.parent_uid);
if (b && b.end_idx >= b.start_idx) {
out.push({
kind: "bundle",
key: `b-${b._uid}`,
bundle: b,
children: bundleChildren(b._uid, b.start_idx, b.end_idx),
});
i = b.end_idx + 1;
} else {
const m = modules[i];
out.push({ kind: "mod", key: m._uid ?? `m-${i}`, module: m, idx: i });
i++;
}
}
return out;
});
const moduleRowCtx: ModuleRowCtx = {
KIND_TITLE,
kindIcon, kindChipModifier, varColorClass,
isCollapsed, isLocked, isInternal, isSeedLockable,
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
severityFor, conflictTooltip, conflictBadgeText,
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
rowGap, draggingModuleUid, recentDropUids, pulseDelayFor,
toggleCollapsed, toggleEnabled, removeModule,
toggleLockOnCard, toggleInternalOnCard,
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
pairingFor,
viaInboundFor,
contributorsFor,
};
provide(ModuleRowCtxKey, moduleRowCtx);
const bundleFrameCtx: BundleFrameCtx = {
bundleChildDriftCount,
isBundleLibraryDrifted,
isBundleMissingFromLibrary,
bundleInternalState,
bundleLockState,
bundleHeaderGap,
isBundleDropTarget,
isBundleSnapshotModified: (b: BundleInstance) => bundleSnapshotModified(b, value.value.modules),
recentDropUids,
pulseDelayFor,
toggleBundleCollapsed,
toggleBundleEnabled,
toggleBundleInternal,
toggleBundleLock,
removeBundle,
openBundleContextMenu,
onBundleDragStart,
onDragEnd,
dropBarFor,
};
provide(BundleFrameCtxKey, bundleFrameCtx);
</script>
<template>
<div
class="wp-context"
:class="{ 'wp-context--muted': isMuted }"
:data-mode-label="isMuted ? muteLabel : undefined"
@dragleave="onContainerLeave"
>
<!-- Node identity strip. A fixed, read-only CODENAME naming THIS
WP_Context node — a stable `adjective-noun` derived from the
litegraph node id (node-codename.ts). POV-independent + unique on
the canvas, NOT editable. Cross-node UI (constraint reach pick-list,
pair popovers) shows the same codename to say WHICH node a target
lives in. Sits above the toolbar/hero so it shows in every state. -->
<div class="wp-node-id">
<span class="wp-node-id__tag">node</span>
<span
class="wp-node-id__chip wp-node-id__chip--fixed"
data-test="node-label"
:title="`Stable id for this Context node — referenced by cross-node constraint UI (${nodeCodename})`"
>
<span class="wp-node-id__text">{{ nodeCodename }}</span>
</span>
</div>
<!-- Corrupt-workflow recovery panel (5.6). Surfaces when JSON parse fails
or returns a non-object. View raw exposes the bad payload so users
can copy it out before resetting. -->
<div v-if="parseError" class="wp-recovery" role="alert">
<div class="wp-recovery__header">
<i class="pi pi-exclamation-triangle wp-recovery__icon" aria-hidden="true"></i>
<div class="wp-recovery__msg">
<strong>Couldn't read this node's saved data.</strong>
<div class="wp-recovery__detail">{{ parseError }}</div>
</div>
</div>
<div class="wp-recovery__actions">
<button type="button" class="wp-recovery__btn" @click="showRaw = !showRaw">
<i class="pi" :class="showRaw ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true"></i>
{{ showRaw ? "Hide raw" : "View raw" }}
</button>
<button
type="button"
class="wp-recovery__btn wp-recovery__btn--danger"
@click="resetCorruptValue"
>
<i class="pi pi-refresh" aria-hidden="true"></i>
Reset to empty
</button>
</div>
<pre v-if="showRaw" class="wp-recovery__raw">{{ parseRaw }}</pre>
</div>
<!-- Page swap: populated layout vs empty hero. mode="out-in" sequences
them — the populated block fully fades out before the empty hero
fades in, so the leaving card and the appearing hero never visually
stack. Recovery panel + drop-zone live outside the swap. -->
<Transition name="wp-page" mode="out-in">
<div
v-if="!isEmpty"
key="populated"
class="wp-page"
@dragover="onListDragOver"
@drop="(ev) => onDrop(ev, null)"
>
<!-- Toolbar: module count + bulk-drift refresh + collapse/expand/toggle-all. -->
<div class="wp-w-toolbar">
<span class="wp-w-toolbar-label">modules</span>
<span class="wp-w-count">{{ enabledCount }} / {{ totalCount }}</span>
<!-- Bulk-refresh drifted. Visible only when at least one module
drifted from its library version; orange accent matches the
per-card drift dot. -->
<button
v-if="driftedCount > 0"
type="button"
class="wp-section-label__bulk wp-section-label__bulk--drift"
:title="`Refresh ${driftedCount} drifted module(s) from the library.`"
data-testid="bulk-refresh-drifted"
@click="refreshAllDrifted"
>
<i class="pi pi-refresh" aria-hidden="true"></i>
<span>refresh {{ driftedCount }} drifted</span>
</button>
<span class="wp-w-toolbar-spacer" />
<button
type="button"
class="wp-btn--icon-sm"
title="Collapse all"
aria-label="Collapse all modules"
@click="collapseAll"
><i class="pi pi-chevron-up" /></button>
<button
type="button"
class="wp-btn--icon-sm"
title="Expand all"
aria-label="Expand all modules"
@click="expandAll"
><i class="pi pi-chevron-down" /></button>
<button
type="button"
class="wp-btn--icon-sm"
title="Toggle all enabled"
aria-label="Toggle all enabled"
@click="toggleAllEnabled"
><i class="pi pi-eye" /></button>
</div>
<div
ref="modulesContainer"
class="wp-modules-frame"
>
<div
class="wp-modules"
:data-suppress-move="suppressMove ? 'true' : null"
>
<!-- Floating drop indicator for top-level scope. Hidden when
the resolved zone targets a nested bundle container — that
container renders its own BundleDropBar. -->
<BundleDropBar :container-uid="null" />
<template v-for="item in topLevelItems" :key="item.key">
<BundleFrame
v-if="item.kind === 'bundle'"
:bundle="item.bundle!"
:children="item.children!"
:nested="false"
/>
<ModuleRow
v-else
:module="item.module!"
:idx="item.idx!"
:data-uid="item.module!._uid"
/>
</template>
</div>
</div>
<!-- Footer: primary add + bundle add. Shown only when list is
non-empty. "Open in SPA" was replaced with "+ Add Bundle"
when the bundle system shipped — bundles ARE the SPA-side
reusable scaffolds users typically wanted that button for.
SPA library still reachable via the right-click context
menu on any module. -->
<div
class="wp-w-footer"
>
<button
class="wp-btn wp-btn--primary"
data-testid="open-picker"
@click="openPicker"
>
<i class="pi pi-plus" /> Add module
</button>
<button
class="wp-btn"
data-testid="open-bundle-picker"
@click="openBundlePicker"
>
<i class="pi pi-plus" /> Add Bundle
</button>
</div>
</div>
<!-- Empty-state hero — shown when modules is empty and there's no
recovery panel up. Brand glyph + dual CTA (add + SPA link). -->
<div
v-else-if="!parseError"
key="empty"
class="wp-empty-hero"
:class="{ 'wp-empty-hero--drop-target': isDropEndZone }"
data-test="context-empty"
@dragover="onEmptyHeroDragOver"
@drop="(ev) => onDrop(ev, null)"
>
<!-- eslint-disable-next-line vue/no-v-html — wpLogoSvg is a static
import from src/components/shared/wp-logo.svg via Vite ?raw,
not user input. Same pattern as extension/topbar.ts. -->
<div
class="wp-empty-hero-glyph"
aria-hidden="true"
v-html="wpLogoSvg"
/>
<div class="wp-empty-hero-title">No modules yet</div>
<div class="wp-empty-hero-sub">
Add a wildcard, fixed values pack, combine, derivation or constraint to start
building this context.
</div>
<div class="wp-empty-hero-actions">
<button
class="wp-btn wp-btn--primary"
data-testid="open-picker"
@click="openPicker"
>
<i class="pi pi-plus" /> Add module
</button>
<button
class="wp-btn"
data-testid="open-bundle-picker"
@click="openBundlePicker"
>
<i class="pi pi-plus" /> Add Bundle
</button>
</div>
</div>
</Transition>
<div
class="wp-drop-end"
:class="{ 'wp-drop-end--active': isDropEndZone, 'wp-drop-end--show': dragState !== null }"
@dragover="onEndDragOver"
@drop="(ev) => onDrop(ev, null)"
>Drop here</div>
<ModulePickerModal
:visible="showPicker"
:already-added="value.modules.map((m: ModuleEntry) => m.id)"
:already-added-ids="value.modules.map((m: ModuleEntry) => m.id)"
@add="onPickerAdd"
@close="showPicker = false"
/>
<BundlePickerModal
:visible="showBundlePicker"
:already-added-ids="(value.bundles ?? []).map((b) => b.library_id)"
@pick="onPickBundle"
@create="openBundleAuthor"
@close="showBundlePicker = false"
/>
<ModuleEditModal
:visible="editingModule !== null"
:module="editingModule"
:upstream-vars="upstreamVars"
:upstream-resolved="resolvedForEditing"
:sibling-vars="siblingNodeVars"
:sibling-modules="value.modules"
:chain-modules="chainModules"
:via-option-pairs="editingModuleViaOptionPairs"
:last-used-seed-reader="lastUsedSeedReader"
@save="saveEditedModule"
@close="editingIdx = null"
/>
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
independently because BundleInstance ≠ ModuleEntry. The live
bundle drives `lockState` / `internalState` (not the draft)
since those toggles cascade onto children, mirroring the
BundleHeader buttons. -->
<ModalShell :visible="bundleDraft !== null" @close="cancelBundleEdit">
<BundleInstanceModal
v-if="bundleDraft !== null && editingBundleEntry"
:bundle="bundleDraft"
:library-name="bundleLibraryDefaults?.name ?? ''"
:library-color="bundleLibraryDefaults?.color ?? null"
:library-drifted="isBundleLibraryDrifted(editingBundleEntry)"
:snapshot-modified="bundleSnapshotModified(editingBundleEntry, value.modules)"
:lock-state="bundleLockState(editingBundleEntry)"
:internal-state="bundleInternalState(editingBundleEntry)"
@update="onBundleEditUpdate"
@save="saveBundleEdit"
@cancel="cancelBundleEdit"
@toggle-lock="onBundleEditToggleLock"
@toggle-internal="onBundleEditToggleInternal"
@save-to-library="onBundleEditSaveToLibrary"
@reset-to-library="onBundleEditResetToLibrary"
@open-spa="cancelBundleEdit"
/>
</ModalShell>
<!-- Standalone push-to-library entry point — fires from the
right-click menu without going through the edit modal first.
Same component as the one mounted inside ModuleEditModal so
either path produces identical behavior. -->
<PushToLibraryModal
:open="pushOpen"
:draft="pushDraft"
@close="onPushClosed"
@saved="onPushSaved"
/>
<!-- Bundle-scoped push-to-library modal — same fork/update grammar
as the module modal, but children list preview instead of a
JSON payload preview. -->
<PushBundleToLibraryModal
:open="pushBundleOpen"
:bundle="pushBundleDraft"
:children-for-library="pushBundleChildrenForLibrary"
:children-preview="pushBundleChildrenPreview"
:cascade-scan="pushBundleCascadeScan"
:cascade-restore="pushBundleCascadeRestore"
@close="onBundlePushClosed"
@saved="onBundlePushSaved"
/>
<ContextMenu
:visible="ctxMenu.visible"
:x="ctxMenu.x"
:y="ctxMenu.y"
:items="ctxMenu.items"
:header="ctxMenu.header"
@close="ctxMenu.visible = false"
/>
<!-- Shared confirm dialog for destructive bundle ops. `pendingConfirm`
is set by `maybeConfirm()` when the user has the relevant
setting enabled; resolves the awaited Promise via the
onConfirm / onCancel callbacks the helper stamped on the
state object. -->
<ConfirmDialog
v-if="pendingConfirm"
:visible="true"
:title="pendingConfirm.title"
:body="pendingConfirm.body"
:variant="pendingConfirm.variant"
:confirm-label="pendingConfirm.confirmLabel"
:cancel-label="pendingConfirm.cancelLabel"
@confirm="pendingConfirm.onConfirm"
@cancel="pendingConfirm.onCancel"
/>
</div>
</template>
<style>
@import "../shared/theme.css";
@import "../shared/row-primitives.css";
@import "./editors/_modal-head.css";
@import "./editors/_modal-template-ctrls.css";
@import "../shared/_modal-motion.css";
</style>
<style>
/* NOT wrapped in @layer wp-extension despite the rest of the
* extension being layered. The block below has ~1000 lines of
* interleaved rules whose cascade priority relative to
* component-scoped (unlayered) Vue rules has been stable. Layering
* this block would flip the priority of dozens of rules that
* currently win source-order — visual regressions in modules,
* bundles, summaries. The .wp-* namespace prefix is the primary
* isolation here; consider re-introducing @layer per-section once
* each section's cascade interaction is verified. */
.wp-context, .wp-context * { box-sizing: border-box; }
/* Mute / bypass dim. Litegraph dims the title bar + node frame natively
* for modes 2/4 but leaves DOM-rendered widget content at full opacity.
* Match the native dim so the runtime-skipped state reads consistently
* — one less "is this thing actually doing anything?" question for the
* user. Module rows stay editable (no pointer-events block); the dim is
* a perceptual cue, not a functional gate. The corner pill names which
* mode is active so muted vs. bypassed are distinguishable at a glance. */
.wp-context--muted {
position: relative;
opacity: 0.45;
filter: saturate(0.6);
transition: opacity var(--wp-motion-hover), filter var(--wp-motion-hover);
}
.wp-context--muted::before {
/* Label dropped — the dim opacity + litegraph's native title bar
* dim are enough signal. Keeping the empty pseudo so the existing
* positioning rules don't dangle (cheap; renders nothing). */
content: "";
position: absolute;
top: 4px;
right: 6px;
z-index: 4;
padding: 0;
border-radius: 999px;
background: transparent;
border: 0;
color: var(--wp-text2);
font-family: var(--wp-font-mono, monospace);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
pointer-events: none;
opacity: 1;
}
.wp-context {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
cursor: default;
}
.wp-modules {
display: flex;
flex-direction: column;
gap: var(--wp-row-gap);
/* Drop-bar anchor — the floating <BundleDropBar :container-uid="null">
* is absolutely positioned and needs this as its offset parent so
* its top/left coordinates resolve against the top-level scope. */
position: relative;
}
/* ── Bundle frame (Phase 2 Task 10b) ───────────────────────────────
* Bundle members + BundleHeader are flat siblings in the flex column.
* The frame is drawn via per-row borders + bg tint, with negative
* margins eliminating the row-gap so the box reads as ONE container.
*
* Bundle children also get a "compact" treatment: action buttons
* hidden (edit via right-click), tighter padding, slightly indented.
* Goal — children read as sub-items of the bundle, not as full
* standalone modules competing for visual weight.
*
* Frame color from `--wp-bundle-color` set inline on each row from
* `bundleStyleForModule()`. Fallback: `--wp-bundle-default`. */
/* BundleHeader carries the frame's top edge + side walls (rendered
* inside its scoped styles, see BundleHeader.vue). Frame walls
* continue from the header's bottom into the children below via
* negative-margin overlap. */
/* Children visually NESTED inside the bundle frame:
* 1. Horizontal margin shrinks them inward — they don't touch the
* frame walls, so the eye reads them as contained
* 2. Tighter padding + smaller name font — they read as sub-items
* 3. Smaller kind-icon + chip — proportional shrink to match
* !important throughout since density-pref `--wp-pad-row` token
* would otherwise win at the same specificity. */
.wp-module--in-bundle {
margin-left: 8px !important;
margin-right: 8px !important;
/* Children render at their default row height — drag handle,
* collapse caret, kind icon all read the same size as standalone
* modules. Tighter padding here squeezed icons visually; reverted
* to inherit `--wp-pad-row` from `.wp-module`.
*
* Background inherits from `.wp-module` (`var(--wp-bg3)`) — no
* !important override here so `.wp-disabled`'s diagonal-stripe
* gradient still wins when the child is disabled. */
}
/* All bundle children — including the first — keep the natural
* flex row-gap of `.wp-modules` so there's breathing room between
* the header and first child, and between siblings inside the
* bundle. The overlay extends 6px below the last child for the
* matching bottom-padding effect. */
/* Last bundle child gets extra margin-bottom so the overlay's
* PAD_BOTTOM (6px below last child) doesn't overlap into the next
* standalone module — keeps a visible gap after the bundle ends. */
.wp-module--bundle-last {
margin-bottom: 8px !important;
}
/* Children render at full default row dimensions — no padding or
* font overrides. Drag handle SVG, collapse caret, kind icon all
* match the size they have on standalone modules.
*
* Belt-and-suspenders: re-declare the canonical sizes here with
* !important so density-pref or other context-class overrides can't
* shrink bundle child controls below their standalone equivalents. */
.wp-module--in-bundle .wp-drag-handle {
width: 6px !important;
}
.wp-module--in-bundle .wp-collapse-btn {
width: 14px !important;
}
.wp-module--in-bundle .wp-collapse-btn .pi {
font-size: 10px !important;
}
.wp-module--in-bundle .wp-toggle-mark {
width: 12px !important;
height: 12px !important;
}
.wp-module--in-bundle .wp-row-type-icon {
width: 16px !important;
height: 16px !important;
}
.wp-module--in-bundle .wp-row-type-icon .pi {
font-size: 12px !important;
line-height: 1 !important;
}
/* Bundle as a real DOM container — header + children inside. Border
* + bg paint via CSS on this div, growing/shrinking with content.
*
* Collapse/expand (Phase B.1) — pure CSS, no v-show, no JS measurement.
* grid-template-rows transitions from `auto 1fr` (expanded) to `auto 0fr`
* (collapsed); the children container needs min-height: 0 + overflow:
* hidden for the row size to collapse to zero. opacity + padding on the
* children fade together with the frame shrink. Header divider snaps off
* at start of collapse and snaps on at end of expand via 0s transition
* with strategic delays. */
.wp-modules-frame { position: relative; }
.wp-bundle {
border: 1px solid var(--wp-bundle-color, var(--wp-bundle-default));
border-left-width: 3px;
border-radius: var(--wp-radius, 4px);
background: color-mix(in srgb, var(--wp-bundle-color, var(--wp-bundle-default)) 5%, transparent);
display: grid;
grid-template-rows: auto 1fr;
transition: grid-template-rows var(--wp-motion-collapse) var(--wp-motion-curve-collapse),
border-width var(--wp-motion-quick) ease,
background var(--wp-motion-quick) ease,
box-shadow var(--wp-motion-quick) ease;
}
.wp-bundle--collapsed {
grid-template-rows: auto 0fr;
}
/* Bundle disabled overlay — dims the entire frame (children included)
* without touching each row's `wp-disabled` class. Children keep their
* own `instance.enabled` state on the workflow side; the bundle gate is
* purely visual. Re-enabling the bundle pops every row back to its
* previous individual state with zero data loss. Matches the
* BundleHeader's diagonal-stripe pattern via the same `.wp-disabled`
* cosmetic vocabulary so a disabled bundle and a disabled module read
* as cousins of the same off-state. */
.wp-bundle--disabled {
opacity: 0.55;
background: repeating-linear-gradient(
45deg,
var(--wp-bg3),
var(--wp-bg3) 6px,
var(--wp-bg2) 6px,
var(--wp-bg2) 8px
);
}
.wp-bundle--disabled .wp-bundle-children {
/* Stripes shine through; lighten children one notch so the bundle
* stripe pattern reads as a single overlay across header + body. */
background: transparent;
}
.wp-bundle-children {
padding: 5px 6px 7px 6px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: hidden;
opacity: 1;
transition: opacity var(--wp-motion-collapse) var(--wp-motion-curve-collapse),
padding var(--wp-motion-collapse) var(--wp-motion-curve-collapse);
}
.wp-bundle--collapsed .wp-bundle-children {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
/* Per-module animation INSIDE a bundle — modules actively fade + slide
* up toward the header during collapse, not just get clipped. This is
* what makes the joint motion visually obvious vs. "frame snaps and
* children disappear instantly". Overrides the default .wp-module
* transition (lines below) for the duration / curve. */
.wp-bundle-children .wp-module {
transition: opacity var(--wp-motion-collapse) var(--wp-motion-curve-collapse),
transform var(--wp-motion-collapse) var(--wp-motion-curve-collapse),
background var(--wp-motion-quick) ease,
border-color var(--wp-motion-quick) ease;
}
.wp-bundle--collapsed .wp-bundle-children .wp-module {
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
/* Drop-target highlight — fires when the slot zone's container is
* this bundle's body. Single rule covers in-bundle reorder AND
* crossing into the bundle from outside, since "drop will land here"
* is the same affordance either way.
*
* Visual mix:
* - 28% bundle-color background tint → frame body brightens in its
* own color (preserves identity, no color clash).
* - 3px solid bundle-color outer ring → frame edge stands forward
* from sibling rows without using a foreign accent hue.
* - 10px soft glow at 60% bundle-color → silhouette pop so the
* affordance reads under ComfyUI's canvas zoom-out.
*
* Earlier version used --wp-accent-glow (15% alpha) — barely visible
* under canvas zoom + against the standard frame border. Switched to
* solid bundle-color so the receiving container reads at a glance. */
.wp-bundle.wp-bundle--drop-target {
background: color-mix(
in srgb,
var(--wp-bundle-color, var(--wp-bundle-default)) 16%,
transparent
);
box-shadow:
0 0 0 2px var(--wp-bundle-color, var(--wp-bundle-default)),
0 0 8px 0 color-mix(
in srgb,
var(--wp-bundle-color, var(--wp-bundle-default)) 30%,
transparent
);
}
/* Header divider — snap off at start of collapse, snap on at END of expand.
* Default rule (no collapsed class) waits MOTION_COLLAPSE_MS before snapping
* border-bottom visible; collapsed rule snaps immediately to 0-width so
* the row consumes NO extra height. Previously the divider stayed at 1px
* with `border-bottom-color: transparent`, which made collapsed bundles
* render 1px taller than collapsed modules — the user spotted that the
* two row types didn't match in compact state. */
.wp-bundle .wp-bundle-header {
transition: border-bottom-color 0s var(--wp-motion-collapse), border-bottom-width 0s var(--wp-motion-collapse);
}
.wp-bundle--collapsed .wp-bundle-header {
border-bottom-width: 0;
border-bottom-color: transparent;
transition: border-bottom-color 0s 0s, border-bottom-width 0s 0s;
}
/* Caret rotation tied to bundle collapsed state — replaces the previous
* icon-class swap (pi-caret-down ↔ pi-caret-right). The BundleHeader
* template now always renders pi-caret-down so the CSS rotation owns
* the transition. */
.wp-bundle .wp-bundle-collapse {
transition: transform var(--wp-motion-collapse) var(--wp-motion-curve-collapse);
}
.wp-bundle--collapsed .wp-bundle-collapse {
transform: rotate(-90deg);
}
/* Populated ↔ Empty page swap. */
.wp-page { display: flex; flex-direction: column; gap: 6px; }
.wp-page-enter-active,
.wp-page-leave-active {
transition: opacity var(--wp-motion-fade) ease;
}
.wp-page-enter-from,
.wp-page-leave-to {
opacity: 0;
}
/* ── Corrupt-workflow recovery (5.6) ──────────────────────────────────── */
.wp-recovery {
background: rgba(248, 113, 113, 0.06);
border: 1px solid var(--wp-red);
border-radius: var(--wp-radius-sm);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-recovery__header { display: flex; gap: 8px; align-items: flex-start; }
.wp-recovery__icon {
color: var(--wp-red);
font-size: 14px;
padding-top: 2px;
flex-shrink: 0;
}
.wp-recovery__msg { flex: 1; min-width: 0; }
.wp-recovery__msg strong {
color: var(--wp-text);
font-size: 12px;
display: block;
}
.wp-recovery__detail {
color: var(--wp-text2);
font-size: 11px;
font-family: var(--wp-font-mono, monospace);
margin-top: 2px;
word-break: break-word;
}
.wp-recovery__actions { display: flex; gap: 6px; }
.wp-recovery__btn {
flex: 1;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
color: var(--wp-text2);
font-family: var(--wp-font-sans, sans-serif);
font-size: 11px;
padding: 4px 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background var(--wp-motion-quick), border-color var(--wp-motion-quick), color var(--wp-motion-quick);
}
.wp-recovery__btn:hover {
background: var(--wp-bg4);
border-color: var(--wp-border2);
color: var(--wp-text);
}
.wp-recovery__btn--danger {
color: var(--wp-red);
border-color: var(--wp-red);
}
.wp-recovery__btn--danger:hover {
background: var(--wp-red-bg);
color: var(--wp-red);
}
.wp-recovery__raw {
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
padding: 6px 8px;
font-family: var(--wp-font-mono, monospace);
font-size: 10px;
color: var(--wp-text3);
max-height: 120px;
overflow: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
/* ── Section label (matches assembler + picker) ──────────────────────── */
.wp-section-label {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--wp-font-mono);
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--wp-text3);
padding: 0 2px;
margin-top: 2px;
}
.wp-section-label__icon {
font-size: 10px;
color: var(--wp-violet);
}
.wp-section-label__count {
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
background: var(--wp-violet-bg);
color: var(--wp-violet);
letter-spacing: 0;
}
/* Bulk collapse/expand toggle — pushed to the right of the row via
* auto-margin. Compact text-button shape so it doesn't clash with
* per-card chevrons + matches the section-label's mono/uppercase
* vibe. */
.wp-section-label__bulk {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid transparent;
border-radius: var(--wp-radius-sm);
color: var(--wp-text3);
cursor: pointer;
font-family: var(--wp-font-mono, monospace);
font-size: 9px;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 2px 6px;
transition: color var(--wp-motion-quick), border-color var(--wp-motion-quick), background var(--wp-motion-quick);
}
.wp-section-label__bulk:hover {
color: var(--wp-text);
border-color: var(--wp-border);
background: var(--wp-bg2);
}
.wp-section-label__bulk .pi { font-size: 9px; }
.wp-section-label__bulk--drift {
border-color: var(--wp-warn);
color: var(--wp-warn);
}
.wp-section-label__bulk--drift:hover {
/* Re-assert drift colour because base `:hover` flips both `color` +
* `border-color` to the neutral text/border tokens; equal-specificity
* selectors lose the cascade race otherwise and the amber accent
* vanishes on hover. */
color: var(--wp-warn);
border-color: var(--wp-warn);
background: color-mix(in oklab, var(--wp-warn) 14%, transparent);
}
/* ── Empty-state hero (Task 10) ─────────────────────────────────────────
* Brand-glyph hero with "No modules yet" copy + dual CTA. Replaces the
* old .wp-emptystate layout (logo + "Wildcard Pipeline" title). */
.wp-empty-hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px dashed var(--wp-border-soft, var(--wp-border2));
border-radius: var(--wp-radius, 4px);
gap: 10px;
text-align: center;
transition: border-color var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease;
}
.wp-empty-hero--drop-target {
border-color: var(--wp-accent);
background: color-mix(in srgb, var(--wp-accent) 8%, var(--wp-bg-deep, var(--wp-bg)));
}
.wp-empty-hero-glyph {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
/* Defensive: SVG paths default to `pointer-events: visiblePainted` and
* the logo's painted area extends to the very edges of its 1024×1024
* viewBox. Keep hit-testing off the glyph regardless of layout
* surprises (overflow, sibling stacking, etc.) so place-mode clicks
* always fall through to the canvas. */
pointer-events: none;
}
.wp-empty-hero-glyph :deep(svg) {
width: 100%;
height: 100%;
}
.wp-empty-hero-title { font: 500 13px/1.3 var(--wp-font-sans); color: var(--wp-text); }
.wp-empty-hero-sub { font: 11px/1.5 var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text2)); max-width: 280px; }
.wp-empty-hero-actions { display: flex; gap: 6px; margin-top: 4px; }
/* Kept for CSS cascade completeness — actual value that gets used in
* empty-state CTA is via .wp-btn--primary below. Placeholder rule to avoid
* breaking any potential refs to these old class names (none after Task 10). */
.wp-emptystate__cta {
background: var(--wp-brand-gradient);
border: none;
border-radius: var(--wp-radius-sm);
color: #fff;
font-family: var(--wp-font-sans);
font-size: 12px;
font-weight: 600;
padding: 9px 18px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
letter-spacing: 0.02em;
/* Dimmed at rest; hover lifts to full brand saturation. */
filter: brightness(0.82) saturate(0.85);
transition: filter var(--wp-motion-hover);
}
.wp-emptystate__cta:hover { filter: brightness(1) saturate(1); }
.wp-emptystate__cta:focus-visible {
outline: 2px solid var(--wp-violet);
outline-offset: 2px;
}
.wp-module {
background: var(--wp-bg3);
border: 1px solid var(--wp-border);
border-left-width: 3px;
border-left-color: var(--wp-kind-wildcard);
border-radius: var(--wp-radius-sm);
padding: var(--wp-pad-row);
display: flex;
flex-direction: column;
/* Gap applied directly to the collapse-row when visible (rule
* below). Flex `gap: 4px` would otherwise paint a 4px band
* BELOW the header even when `.wp-collapse-row[data-collapsed=
* "true"]` has 0 track height, pushing the header off the
* module's visual mid-line. Bundle had the matching 1px header
* border-bottom residual fixed in a separate rule below so
* collapsed bundles and collapsed modules now both render flush
* around their header. */
/* `position: relative` is the anchor for the ::before / ::after
* insertion-line pseudos used by the drop indicators below. */
position: relative;
transition: background-color var(--wp-motion-hover), border-color var(--wp-motion-hover), transform var(--wp-motion-hover), box-shadow var(--wp-motion-hover);
}
.wp-module .wp-collapse-row { margin-top: 4px; }
.wp-module .wp-collapse-row[data-collapsed="true"] { margin-top: 0; }
/* Kind border-left color — driven by data-kind attribute (Task 8). */
.wp-module[data-kind="combine"] { border-left-color: var(--wp-kind-combine); }
.wp-module[data-kind="derivation"] { border-left-color: var(--wp-kind-derivation); }
.wp-module[data-kind="constraint"] { border-left-color: var(--wp-kind-constraint); }
.wp-module[data-kind="fixed_values"] { border-left-color: var(--wp-kind-fixed); }
.wp-module:hover {
border-color: var(--wp-border2);
background: var(--wp-bg4);
}
.wp-module:focus {
outline: none;
border-color: var(--wp-accent);
box-shadow: 0 0 0 1px var(--wp-accent);
}
.wp-module:focus-visible {
outline: none;
border-color: var(--wp-accent);
box-shadow: 0 0 0 2px var(--wp-accent-glow);
}
.wp-module.wp-disabled {
opacity: 0.55;
background: repeating-linear-gradient(
45deg,
var(--wp-bg3),
var(--wp-bg3) 6px,
var(--wp-bg2) 6px,
var(--wp-bg2) 8px
);
}
.wp-module.wp-disabled .wp-module-name { color: var(--wp-text3); }
/* Card-border tint by state — severity cascade (last selector wins).
* Order: info → modified → warning → drift → missing → error. */
.wp-module.wp-conflict-info { border-color: var(--wp-accent); }
.wp-module.wp-state-modified { border-color: var(--wp-status-modified); }
.wp-module.wp-conflict-warning { border-color: var(--wp-amber); }
.wp-module.wp-state-drift { border-color: var(--wp-warn); }
.wp-module.wp-state-missing { border-color: var(--wp-danger); }
.wp-module.wp-conflict-error { border-color: var(--wp-red); }
/* Status-state full-border + bg tint, kind border-left preserved. */
.wp-module.wp-mod--mod { border-color: var(--wp-status-modified); background: color-mix(in srgb, var(--wp-status-modified) 8%, var(--wp-bg3)); }
.wp-module.wp-mod--drift { border-color: var(--wp-warn); background: color-mix(in srgb, var(--wp-warn) 8%, var(--wp-bg3)); }
.wp-module.wp-mod--err { border-color: var(--wp-danger); background: color-mix(in srgb, var(--wp-danger) 8%, var(--wp-bg3)); }
/* Gap metaphor: target row gets margin so a slot opens; the
* `.wp-gap-bar` element paints inside that slot. Margin transitions
* via the row's existing `transition: transform/...` rule below; we
* extend it to cover `margin`. */
.wp-module.wp-gap-before,
.wp-bundle-header.wp-gap-before,
.wp-w-footer.wp-gap-before { margin-top: 14px; }
.wp-module.wp-gap-after { margin-bottom: 14px; }
/* Margins snap (no transition) so back-to-back zone changes don't
* leave half-open gaps mid-flight. Transform/opacity/etc still animate
* via TransitionGroup's FLIP rules + the wp-list-* classes below. */
.wp-modules .wp-module {
transition: transform var(--wp-motion-fade) var(--wp-motion-curve-flip),
background var(--wp-motion-quick) ease, border-color var(--wp-motion-quick) ease, opacity var(--wp-motion-quick) ease;
}
.wp-bundle-header { transition: border-bottom-color var(--wp-motion-fade) ease, background var(--wp-motion-quick) ease; }
.wp-bundle {
transition: border-width var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease, box-shadow var(--wp-motion-quick) ease;
}
.wp-bundle.wp-gap-before { margin-top: 14px; }
.wp-bundle.wp-gap-after { margin-bottom: 14px; }
/* Source ghost — lift the row while it's mid-drag. */
.wp-module--dragging {
opacity: 0.45;
transform: scale(0.98);
}
.wp-bundle--dragging {
opacity: 0.55;
transform: scale(0.99);
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
}
/* Drop-pulse + library-flash + drop-shake keyframes → moved to
* src/components/shared/row-primitives.css so InjectorRow can use
* the same animations. .wp-module--flash kept here as an alias to
* the shared .wp-row-flash so existing flip.ts default classes
* still work for Context's library-op flash path. */
.wp-module--flash { animation: wp-row-flash var(--wp-motion-pulse) ease-out; }
.wp-module--shake { animation: wp-row-shake var(--wp-motion-swap) ease-in-out; }
/* Module header — flex row with every control centered on the
* row's mid-line. Mirrors `.wp-bundle-header`'s typography stack
* (`font: 500 11px/1.4 sans`) so a standalone module row and a
* bundle header read with identical chrome — same baseline rules,
* same visual heft. `.wp-row-type-icon .pi { line-height: 1 }`
* keeps the PrimeIcons glyphs from inheriting the 1.4 line-box,
* which would overflow their fixed-size container on Windows + DPI
* != 1 and round the icon a hair above the action-button siblings. */
.wp-module-header {
display: flex;
align-items: center;
gap: 6px;
font: 500 11px/1.4 var(--wp-font-sans);
color: var(--wp-text);
}
.wp-module-header > * { align-self: center; }
.wp-module-header .wp-row-type-icon .pi { line-height: 1; }
/* Drag handle — grab cursor scoped here, full card stays draggable. */
/* .wp-drag-handle base styles → src/components/shared/row-primitives.css */
.wp-module:hover .wp-drag-handle,
.wp-module:focus-within .wp-drag-handle { opacity: 1; color: var(--wp-text2); }
/* .wp-collapse-btn base → src/components/shared/row-primitives.css.
* Module-specific: PrimeIcons sets 16px on `.pi` itself; our shared
* rule sets 10px which matches what Context wanted. */
.wp-toggle { display: flex; align-items: center; cursor: pointer; flex-shrink: 0; }
.wp-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.wp-toggle-mark {
display: block;
width: 12px;
height: 12px;
border-radius: 2px;
border: 1px solid var(--wp-border2);
background: var(--wp-bg2);
transition: background-color var(--wp-motion-hover), border-color var(--wp-motion-hover), box-shadow var(--wp-motion-hover);
}
.wp-toggle input:checked + .wp-toggle-mark { background: var(--wp-accent); border-color: var(--wp-accent); }
.wp-toggle input:focus-visible + .wp-toggle-mark {
/* Distinct from accent so the ring stays visible whether the toggle is
* on (accent fill) or off (transparent fill). */
box-shadow: 0 0 0 2px var(--wp-violet);
}
/* Type icon — colored per type so users scan-recognize the module type */
.wp-type-icon {
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
}
.wp-type-icon.type-fixed_values { color: var(--wp-kind-fixed, var(--wp-rose)); }
.wp-type-icon.type-wildcard { color: var(--wp-kind-wildcard); }
.wp-type-icon.type-combine { color: var(--wp-kind-combine); }
.wp-type-icon.type-derivation { color: var(--wp-kind-derivation); }
.wp-type-icon.type-constraint { color: var(--wp-kind-constraint); }
/* Kind icon — canonical PrimeIcons per module type (Task 8).
* Base styles live in src/components/shared/row-primitives.css under
* `.wp-row-type-icon`. Color follows the same --wp-kind-* token map
* as the border-left. Default color override (wildcard) + bump font
* size to 12 to match Module density. */
.wp-row-type-icon { color: var(--wp-kind-wildcard); }
.wp-row-type-icon .pi { font-size: 12px; }
.wp-module[data-kind="combine"] .wp-row-type-icon { color: var(--wp-kind-combine); }
.wp-module[data-kind="derivation"] .wp-row-type-icon { color: var(--wp-kind-derivation); }
.wp-module[data-kind="constraint"] .wp-row-type-icon { color: var(--wp-kind-constraint); }
.wp-module[data-kind="fixed_values"] .wp-row-type-icon { color: var(--wp-kind-fixed); }
.wp-module-name {
flex: 1;
font-size: var(--wp-mod-font);
color: var(--wp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* .wp-conflict-dot + .wp-conflict-badge + severity variants →
* src/components/shared/row-primitives.css. Kept commented anchor
* here for grep discoverability — every row-list widget consumes
* the same cluster (ModuleRow, InjectorRow, future Debug rows). */
/* Cluster wrapper — keeps every status dot (modified, missing,
* conflict) on one inline run so they never get separated by
* interactive controls. Empty cluster collapses (no children → no
* gap), so cards without indicators look unchanged. */
.wp-mod-dots {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
/* Cap so a long stack of state badges (mod + drift + missing +
* override) doesn't push action buttons off the row. Wrap to a
* second line if needed — module card grows slightly taller, but
* the name + action cluster stay aligned. */
max-width: 50%;
justify-content: flex-end;
}
.wp-mod-dots:empty { display: none; }
/* Per-instance / library-state dots — distinct from conflict dots
* (which signal STRUCTURAL graph issues). All three share the SPA
* `.wp-chip--*` color triple (bg = token@14%, border = token@36%) so
* dots, IO badges, and chips all encode "this hue means X" the same
* way. ComfyUI's canvas zooms widgets down hard, so shape-based
* differentiation (rings, dashed borders) disappears at typical view
* distances — colour does all the work.
*
* Token map — kept in sync with `src/manager/views/ImportExport.vue`:
* modified → --wp-status-modified (SPA "mod" — user diff)
* drift → --wp-warn (SPA "exists" — overwriteable)
* missing → --wp-danger (no SPA twin — gone from lib)
*/
.wp-mod-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
cursor: help;
border: 1px solid transparent;
}
/* Bg stays chip-style translucent (14%), but border uses the full
* token hue: at 7×7 + canvas zoom-out, a 36% border like the SPA
* chips fades into the card surface. Pills at 11px+ get to be
* subtle; dots at canvas scale need the saturation. */
.wp-mod-dot--modified {
background: color-mix(in oklab, var(--wp-status-modified) 14%, transparent);
border-color: var(--wp-status-modified);
}
.wp-mod-dot--drift {
background: color-mix(in oklab, var(--wp-warn) 14%, transparent);
border-color: var(--wp-warn);
}
.wp-mod-dot--missing {
background: color-mix(in oklab, var(--wp-danger) 14%, transparent);
border-color: var(--wp-danger);
}
/* clash → --wp-accent (violet): id reused by a DIFFERENT kind. Distinct
* from modified/drift/missing so a cross-kind collision reads at a glance. */
.wp-mod-dot--clash {
background: color-mix(in oklab, var(--wp-accent) 14%, transparent);
border-color: var(--wp-accent);
}
/* ── Sibling badge ──────────────────────────────────────────────────────
* Rendered when the same uuid appears more than once in this Context.
* Phase A: count display only. Phase B will wire auto-fork prompt. */
/* .wp-mod-badge + .wp-mod-badge--* + .wp-mod-badge--sibling →
* src/components/shared/row-primitives.css. */
/* Status text-badges (mockup v5 lines 714, 736, 861) — kind-tinted
* label that pairs with the matching `.wp-mod-dot--*` so users see
* BOTH the colour glance and the textual state. Kind palette is the
* same triple used by the dot cluster:
* --wp-status-modified → "mod" (user diff vs library)
* --wp-warn → "drift" (library has a newer version)
* --wp-warn → "no link" (Injector socket — re-uses drift hue)
* --wp-danger → "missing" (gone from library)
*/
/* Hover cursor for the status text badges — kept here (not in
* shared) because cursor:help is a Context-row-specific UX
* decision (the badges have hover-tooltips that explain the state). */
.wp-mod-badge--mod,
.wp-mod-badge--drift,
.wp-mod-badge--missing { cursor: help; }
/* .wp-mod-badge--mod/drift/missing color rules → row-primitives.css */
/* `.wp-kind-chip` rules live in shared/theme.css so the chip stays
* visually identical across the row, picker, and edit-modal header.
* Kept as a comment-only anchor here for grep discoverability. */
/* ── Inline action cluster (lock + internal + remove) ───────────────────
* Always visible (was hover-revealed) — discoverability over chrome.
* Uses PrimeIcons via `pi` class (Task 9). Replaces the old
* wp-card-toggle + wp-icon-btn / wp-delete pattern. */
.wp-mod-actions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
/* .wp-btn--icon-sm + variants (.is-active, .is-locked, .wp-btn--danger)
* → src/components/shared/row-primitives.css. Used by ModuleRow,
* BundleHeader, InjectorRow, and any future row surface that needs
* a small icon button. */
/* .wp-summary + .wp-summary__main base styles → row-primitives.css.
* Default padding-left:36px aligns under the module header's icon
* column (drag+collapse+toggle+icon = 36px); InjectorRow can
* override in its scoped block if its header column widths differ. */
.wp-summary__sibling {
font-family: var(--wp-font-mono);
font-size: 9px;
color: var(--wp-text2);
background: color-mix(in srgb, var(--wp-text3) 22%, transparent);
padding: 2px 6px;
border-radius: 999px;
flex-shrink: 0;
cursor: help;
}
/* Var tokens inside the summary — re-use the same kind-color hashing
* as the assembler chip strip + combine preview so the eye reads
* `$hair_style` as the same hue everywhere it appears. Color comes
* from the `.var-N` class set by `varColorClass()` (defined in the
* shared theme); we only bump font-weight here. NO color override —
* Vue's scoped specificity would beat the unscoped `.var-N` rule
* and force the fallback colour, which would silently blank the
* highlighting (the user-facing bug fixed here). */
.wp-summary .var-tok {
font-weight: 600;
}
/* ── Toolbar (Task 10) ──────────────────────────────────────────────────
* Replaces wp-section-label for the populated header row. */
.wp-w-toolbar { display: flex; align-items: center; gap: 4px; padding: 4px 0 8px; }
.wp-w-toolbar-label { font: 500 11px/1 var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text3)); text-transform: lowercase; letter-spacing: 0.02em; }
.wp-w-count { font: 500 11px/1 var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); padding: 2px 6px; background: var(--wp-bg-deep, var(--wp-bg)); border-radius: var(--wp-radius, 4px); }
.wp-w-toolbar-spacer { flex: 1; }
/* ── Node identity chip (SP3 P7) ────────────────────────────────────────
* A small, unobtrusive header strip naming this WP_Context node. Amber
* accent (matches the design proposal); sits above the toolbar/hero in a
* compact row so it never disturbs their layout. */
.wp-node-id { display: flex; align-items: center; gap: 6px; padding: 2px 0 4px; }
.wp-node-id__tag {
font: 500 10px/1 var(--wp-font-sans, sans-serif);
color: var(--wp-text-dim, var(--wp-text3));
text-transform: uppercase;
letter-spacing: 0.06em;
}
.wp-node-id__chip {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 2px 8px;
font: 600 11px/1.2 var(--wp-font-sans, sans-serif);
/* Neutral — a fixed codename is an identity, not an attention cue (was
amber). Muted text + a faint text-tinted wash + standard border, the
same neutral idiom used elsewhere in the widget; reads on light + dark. */
color: var(--wp-text-muted, var(--wp-text2));
background: color-mix(in srgb, var(--wp-text) 6%, transparent);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm, 4px);
}
/* Fixed codename — a read-only identity, not an interactive control. */
.wp-node-id__chip--fixed { cursor: default; }
.wp-node-id__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Generic button system (Task 10) ────────────────────────────────────
* .wp-btn--icon-sm (Task 9) already exists for inline card actions;
* these are the larger toolbar/footer variants. */
.wp-btn {
background: var(--wp-bg2);
border: 1px solid var(--wp-border2);
color: var(--wp-text);
font: 500 11px/1 var(--wp-font-sans);
padding: 5px 9px;
border-radius: var(--wp-radius, 4px);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: background var(--wp-motion-quick) ease, border-color var(--wp-motion-quick) ease;
}
.wp-btn:hover { background: var(--wp-bg4); border-color: var(--wp-border, var(--wp-border2)); }
.wp-btn--icon { padding: 5px 6px; width: 26px; height: 26px; justify-content: center; }
.wp-btn--icon .pi { font-size: 12px; }
.wp-btn--primary { background: var(--wp-accent); border-color: var(--wp-accent); color: #fff; }
.wp-btn--primary:hover { background: var(--wp-accent); border-color: var(--wp-accent); filter: brightness(1.08); }
/* ── Footer (Task 10) ───────────────────────────────────────────────── */
.wp-w-footer { display: flex; gap: 4px; padding-top: 4px; border-top: 1px dashed var(--wp-border-soft, var(--wp-border2)); }
.wp-w-footer .wp-btn { flex: 1; justify-content: center; }
.wp-drop-end {
display: none;
border: 1px dashed var(--wp-border2);
border-radius: var(--wp-radius-sm);
color: var(--wp-text3);
font-size: 11px;
text-align: center;
padding: 6px;
margin-top: 4px;
font-style: italic;
}
.wp-drop-end--show { display: block; }
.wp-drop-end--active {
border-color: var(--wp-accent);
background: var(--wp-accent-glow);
color: var(--wp-accent);
}
/* ── Animations ─────────────────────────────────────────────────────── */
/* FLIP reorder — TransitionGroup applies wp-list-move when items reorder. */
.wp-list-move { transition: transform var(--wp-motion-flip) ease-out; }
/* Leave is instant. `.wp-module` declares `transition: transform var(--wp-motion-hover)`
* as its base, which makes Vue's TransitionGroup wait for a phantom
* transitionend before tearing leaving rows out. Killing the transition
* on leave-active short-circuits that wait. */
.wp-list-leave-active { transition: none !important; }
/* Bundle delete / bundle drag suppress both FLIP move AND leave-active
* transitions for the full 300ms hold so the overlay, children, and
* follow-up rows all reposition on the same paint (#5 jank + #8). */
.wp-modules[data-suppress-move="true"] .wp-list-move,
.wp-modules[data-suppress-move="true"] .wp-list-leave-active {
transition: none !important;
}
/* Items entering the list (e.g. add via picker) — fade + slide in.
* Leave is intentionally instant; the dying card lingering during a
* fade-out felt sluggish, especially when chained with a FLIP move. */
.wp-list-enter-active { transition: opacity var(--wp-motion-swap), transform var(--wp-motion-swap); }
.wp-list-enter-from { opacity: 0; transform: translateY(-4px); }
/* ── Cross-container enter/leave + module add/remove (Phase B.3 + B.6) ──
* Manual orchestration via flip.ts:withEnterAnimation/withLeaveAnimation.
* No TransitionGroup — Batch 2 ghosting risk. Classes applied + removed
* by orchestration helpers; a row is in one state at a time. */
.wp-module.wp-module--leaving,
.wp-bundle.wp-module--leaving {
opacity: 0;
transform: translateX(-12px);
transition: opacity var(--wp-motion-fade) var(--wp-motion-curve-linear),
transform var(--wp-motion-flip) var(--wp-motion-curve-flip);
pointer-events: none;
}
.wp-module.wp-module--arriving,
.wp-bundle.wp-module--arriving {
/* Snap to from-state. Without `transition: none` the base
* `.wp-modules .wp-module` transition (opacity var(--wp-motion-quick)) starts fading
* the row OUT when we add --arriving, then --arrived fights back —
* net effect is a barely-visible flicker instead of clear fade-in. */
opacity: 0;
transform: translateX(12px);
transition: none;
}
.wp-module.wp-module--arrived,
.wp-bundle.wp-module--arrived {
opacity: 1;
transform: translateX(0);
transition: opacity var(--wp-motion-fade) var(--wp-motion-curve-linear),
transform var(--wp-motion-flip) var(--wp-motion-curve-flip);
}
/* `.wp-collapse-row` utility → row-primitives.css. Wraps the summary
* line for both ModuleRow and InjectorRow, interpolating a grid track
* from 1fr → 0fr (same trick as `.wp-bundle` and
* `.wp-inj-rows-wrap`). Replaces the older `wp-collapse` Vue
* transition that capped at a 32px max-height. */
/* Pulse on first appear — applies uniformly to every state-marker dot
* (mod / drift / missing AND every conflict severity) so the user gets
* the same visual cue regardless of which state surfaced. Previously
* only `.wp-conflict-dot` carried the animation, which made conflict
* dots feel different from mod-state dots in side-by-side testing. */
.wp-conflict-dot,
.wp-mod-dot {
animation: wp-pulse 800ms ease-out;
}
@keyframes wp-pulse {
0% { transform: scale(0.4); opacity: 0; }
40% { transform: scale(1.4); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
</style>