diff --git a/apps/mesh/ct/harness/variant-harnesses.tsx b/apps/mesh/ct/harness/variant-harnesses.tsx
index 28d98969de..86737f143e 100644
--- a/apps/mesh/ct/harness/variant-harnesses.tsx
+++ b/apps/mesh/ct/harness/variant-harnesses.tsx
@@ -35,12 +35,14 @@ export function SectionVariantListHarness({
return (
push({ type: "select", index })}
onDuplicate={(index) => push({ type: "duplicate", index })}
onDelete={(index) => push({ type: "delete", index })}
onRemoveAll={() => push({ type: "removeAll" })}
+ onReorder={(from, to) => push({ type: "reorder", from, to })}
onAdd={() => push({ type: "add" })}
/>
diff --git a/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts b/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts
index b40df9b9cd..4ad1ac903a 100644
--- a/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts
+++ b/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts
@@ -24,6 +24,45 @@ export const CMS_EDITOR_SCRIPT = `(function() {
if (window.__cmsEditorActive) return;
window.__cmsEditorActive = true;
+ // Preserve scroll across variant-override navigations and save-reloads.
+ // Selecting a variant rebuilds the iframe src (new x-deco-matchers-override)
+ // or reloads it, which would otherwise jump to the top. We persist the scroll
+ // offset to sessionStorage (survives reload, same-origin) keyed by pathname
+ // (the override only changes the query, so the key is stable), then restore it
+ // on the next load — retried for ~1s so it outlasts re-hydration and lazy
+ // sections that grow the page after load. The recency guard avoids restoring a
+ // stale offset on a genuinely fresh visit.
+ var SCROLL_KEY = "__cms_preview_scroll:" + location.pathname;
+ var saveScroll = function() {
+ try {
+ sessionStorage.setItem(SCROLL_KEY, JSON.stringify({
+ x: window.scrollX, y: window.scrollY, t: Date.now()
+ }));
+ } catch (_) {}
+ };
+ var saveScrollPending = false;
+ window.addEventListener("scroll", function() {
+ if (saveScrollPending) return;
+ saveScrollPending = true;
+ requestAnimationFrame(function() { saveScrollPending = false; saveScroll(); });
+ }, { passive: true });
+ (function restoreScroll() {
+ var raw = null;
+ try { raw = sessionStorage.getItem(SCROLL_KEY); } catch (_) {}
+ if (!raw) return;
+ var saved = null;
+ try { saved = JSON.parse(raw); } catch (_) { return; }
+ if (!saved || (Date.now() - saved.t) > 10000) return;
+ if (!saved.x && !saved.y) return;
+ var attempts = 0;
+ var apply = function() {
+ attempts++;
+ window.scrollTo(saved.x || 0, saved.y || 0);
+ if (attempts < 8) setTimeout(apply, 120);
+ };
+ apply();
+ })();
+
var highlight = document.createElement("div");
highlight.style.cssText = "position:absolute;pointer-events:none;outline:2px solid #06b6d4;background:rgba(6,182,212,0.06);border-radius:2px;z-index:2147483647;display:none;";
document.body.appendChild(highlight);
diff --git a/apps/mesh/src/web/components/sandbox/preview/preview.tsx b/apps/mesh/src/web/components/sandbox/preview/preview.tsx
index 0e04746867..46dce1fc6d 100644
--- a/apps/mesh/src/web/components/sandbox/preview/preview.tsx
+++ b/apps/mesh/src/web/components/sandbox/preview/preview.tsx
@@ -42,6 +42,7 @@ import {
DropdownMenuTrigger,
} from "@deco/ui/components/dropdown-menu.tsx";
import { useDecofile } from "@/web/components/sections-editor/use-decofile";
+import { withVariantMatcherOverride } from "@/web/components/sections-editor/variant-matcher-override";
import { parseSections } from "@/web/components/sections-editor/parse-sections";
import { getPageVariantSectionsAt } from "@/web/components/sections-editor/page-variants";
import { useLiveMeta } from "@/web/components/sections-editor/use-live-meta";
@@ -209,6 +210,11 @@ export function PreviewContent() {
const [cmsSelectedSectionIndex, setCmsSelectedSectionIndex] = useState<
number | null
>(null);
+ // `x-deco-matchers-override` params forcing the preview to render the section
+ // variant currently selected in the sections editor (CMS mode only).
+ const [variantOverrideParams, setVariantOverrideParams] = useState<
+ string[] | null
+ >(null);
const [panelWidth, setPanelWidth] = useState(384);
const isResizingRef = useRef(false);
const resizeStartXRef = useRef(0);
@@ -358,10 +364,13 @@ export function PreviewContent() {
const iframeSrc =
previewState.kind === "iframe"
- ? withDeviceHint(
- directPreviewUrl ??
- new URL(currentPath, previewState.previewUrl).href,
- previewDeviceSize,
+ ? withVariantMatcherOverride(
+ withDeviceHint(
+ directPreviewUrl ??
+ new URL(currentPath, previewState.previewUrl).href,
+ previewDeviceSize,
+ ),
+ sectionsOpen && variantOverrideParams ? variantOverrideParams : [],
)
: null;
@@ -533,7 +542,10 @@ export function PreviewContent() {
// Leaving code mode clears the "View JSON" deep-link so re-entering code
// mode later opens the file tree, not the previously-viewed page JSON.
if (mode !== "code") setCodeFilePath(null);
- if (mode !== "cms") setCmsSelectedSectionIndex(null);
+ if (mode !== "cms") {
+ setCmsSelectedSectionIndex(null);
+ setVariantOverrideParams(null);
+ }
if (prev === "visual") deactivateVisualEditor();
if (prev === "cms") deactivateCmsEditor();
if (mode === "visual") injectVisualEditor();
@@ -548,6 +560,40 @@ export function PreviewContent() {
iframe.src = iframeSrc;
};
+ // Selecting/reordering a variant changes `iframeSrc` (override params), which
+ // re-navigates the iframe. Scroll position is preserved entirely inside the
+ // injected CMS script (it persists scroll to sessionStorage and restores it
+ // after the reload, surviving re-hydration), so nothing to capture here.
+ const handleVariantPreviewOverride = (params: string[] | null) => {
+ setVariantOverrideParams(params);
+ };
+
+ // Reload the preview after a save while preventing the iframe from stealing
+ // focus. Scroll is restored by the injected CMS script (see above).
+ const reloadPreviewPreservingScroll = () => {
+ const iframe = previewIframeRef.current;
+ if (!iframe) return;
+ const focused = document.activeElement as HTMLElement | null;
+ const prevTabIndex = iframe.tabIndex;
+ iframe.tabIndex = -1;
+ iframe.style.pointerEvents = "none";
+ iframe.blur();
+ try {
+ iframe.contentWindow?.location.reload();
+ } catch {
+ const src = iframeSrcRef.current;
+ if (src) iframe.src = src;
+ }
+ const restore = () => {
+ iframe.tabIndex = prevTabIndex;
+ iframe.style.pointerEvents = "";
+ focused?.focus();
+ iframe.removeEventListener("load", restore);
+ };
+ iframe.addEventListener("load", restore);
+ setTimeout(restore, 3000);
+ };
+
const handleHardReload = () => {
if (!previewIframeRef.current || !iframeSrc) return;
const sep = iframeSrc.includes("?") ? "&" : "?";
@@ -1084,31 +1130,10 @@ export function PreviewContent() {
activeGlobalSection ? null : cmsSelectedSectionIndex
}
onSaved={() => {
- setTimeout(() => {
- const iframe = previewIframeRef.current;
- if (!iframe) return;
- // Prevent iframe from stealing focus during reload
- const focused =
- document.activeElement as HTMLElement | null;
- const prevTabIndex = iframe.tabIndex;
- iframe.tabIndex = -1;
- iframe.style.pointerEvents = "none";
- iframe.blur();
- try {
- iframe.contentWindow?.location.reload();
- } catch {
- const src = iframeSrcRef.current;
- if (src) iframe.src = src;
- }
- const restore = () => {
- iframe.tabIndex = prevTabIndex;
- iframe.style.pointerEvents = "";
- focused?.focus();
- iframe.removeEventListener("load", restore);
- };
- iframe.addEventListener("load", restore);
- setTimeout(restore, 3000);
- }, DEV_SERVER_SETTLE_MS);
+ setTimeout(
+ reloadPreviewPreservingScroll,
+ DEV_SERVER_SETTLE_MS,
+ );
}}
initialEditSeo={cmsInitialEditSeo}
onExitSeo={() => setCmsInitialEditSeo(false)}
@@ -1120,6 +1145,7 @@ export function PreviewContent() {
toast.error("Invalid page block key");
}
}}
+ onVariantPreviewOverride={handleVariantPreviewOverride}
/>
diff --git a/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx b/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx
index 8cfcd3b1c9..ae66db4cdd 100644
--- a/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx
+++ b/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx
@@ -1,3 +1,4 @@
+import { useRef, useState } from "react";
import { Button } from "@deco/ui/components/button.tsx";
import {
Tooltip,
@@ -6,6 +7,7 @@ import {
} from "@deco/ui/components/tooltip.tsx";
import {
Copy01,
+ DotsGrid,
DotsHorizontal,
LayoutAlt01,
Plus,
@@ -18,6 +20,25 @@ import {
DropdownMenuTrigger,
} from "@deco/ui/components/dropdown-menu.tsx";
import { cn } from "@deco/ui/lib/utils.js";
+import {
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type DragStartEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ arrayMove,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
const VARIANT_ICON_COLOR = "oklch(0.65 0.15 160)";
const VARIANT_ROW_CLASS =
@@ -32,25 +53,135 @@ export interface SectionVariantEntry {
label: string;
}
-function VariantRow({
- variant,
+interface SortableVariantEntry {
+ id: string;
+ index: number;
+ label: string;
+}
+
+function variantsDisplayKey(variants: SectionVariantEntry[]): string {
+ return variants.map((variant) => variant.label).join("\n");
+}
+
+function createEntries(
+ variants: SectionVariantEntry[],
+): SortableVariantEntry[] {
+ return variants.map((variant) => ({
+ id: crypto.randomUUID(),
+ index: variant.index,
+ label: variant.label,
+ }));
+}
+
+function remapEntryIndices(
+ entries: SortableVariantEntry[],
+): SortableVariantEntry[] {
+ return entries.map((entry, index) => ({ ...entry, index }));
+}
+
+function VariantRowContent({
+ label,
+ canDelete,
+ dragging,
+ onDuplicate,
+ onDelete,
+}: {
+ label: string;
+ canDelete: boolean;
+ dragging?: boolean;
+ onDuplicate?: () => void;
+ onDelete?: () => void;
+}) {
+ return (
+ <>
+
+
+
+ {label}
+
+
+ {!dragging && (
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onDuplicate?.();
+ }}
+ >
+
+ Duplicate
+
+ {
+ e.stopPropagation();
+ onDelete?.();
+ }}
+ >
+
+ Delete
+
+
+
+ )}
+ >
+ );
+}
+
+function SortableVariantRow({
+ entry,
selected,
canDelete,
onSelect,
onDuplicate,
onDelete,
}: {
- variant: SectionVariantEntry;
+ entry: SortableVariantEntry;
selected: boolean;
canDelete: boolean;
onSelect: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
+ useSortable({ id: entry.id, animateLayoutChanges: () => false });
+
+ const style = {
+ transform: CSS.Transform.toString(
+ transform ? { ...transform, x: 0 } : null,
+ ),
+ opacity: isDragging ? 0 : undefined,
+ };
+
return (
{
if (e.target !== e.currentTarget) return;
@@ -60,78 +191,125 @@ function VariantRow({
}
}}
className={cn(
- "group flex select-none items-center gap-2 rounded-md px-2 py-2.5 transition-colors cursor-pointer",
+ "group flex select-none items-center gap-2 rounded-md px-2 py-2.5 transition-colors touch-none",
+ isDragging ? "cursor-grabbing" : "cursor-grab",
selected ? VARIANT_SELECTED_ROW_CLASS : VARIANT_ROW_CLASS,
)}
>
-
-
- {variant.label}
-
-
-
-
-
-
-
- {
- e.stopPropagation();
- onDuplicate();
- }}
- >
-
- Duplicate
-
- {
- e.stopPropagation();
- onDelete();
- }}
- >
-
- Delete
-
-
-
);
}
export function SectionVariantList({
+ listKey,
variants,
selectedIndex,
onSelect,
onDuplicate,
onDelete,
onRemoveAll,
+ onReorder,
onAdd,
}: {
+ listKey: string;
variants: SectionVariantEntry[];
selectedIndex: number;
onSelect: (index: number) => void;
onDuplicate: (index: number) => void;
onDelete: (index: number) => void;
onRemoveAll: () => void;
+ onReorder: (fromIndex: number, toIndex: number) => void;
onAdd: () => void;
}) {
const canDelete = variants.length > 1;
+ const [entries, setEntries] = useState(() =>
+ createEntries(variants),
+ );
+ const [activeEntry, setActiveEntry] = useState(
+ null,
+ );
+ const [prevListKey, setPrevListKey] = useState(listKey);
+ const [prevVariantCount, setPrevVariantCount] = useState(variants.length);
+ const [prevDisplayKey, setPrevDisplayKey] = useState(() =>
+ variantsDisplayKey(variants),
+ );
+ const suppressClickRef = useRef(false);
+
+ const displayKey = variantsDisplayKey(variants);
+
+ if (prevListKey !== listKey) {
+ setPrevListKey(listKey);
+ setPrevVariantCount(variants.length);
+ setPrevDisplayKey(displayKey);
+ setEntries(createEntries(variants));
+ } else if (prevVariantCount !== variants.length) {
+ setPrevVariantCount(variants.length);
+ setPrevDisplayKey(displayKey);
+ setEntries(createEntries(variants));
+ } else if (prevDisplayKey !== displayKey) {
+ setPrevDisplayKey(displayKey);
+ setEntries((current) =>
+ variants.map((variant, index) => {
+ const prior = current[index];
+ return {
+ id: prior?.id ?? crypto.randomUUID(),
+ index: variant.index,
+ label: variant.label,
+ };
+ }),
+ );
+ }
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const entryIds = entries.map((entry) => entry.id);
+
+ const handleDragStart = (event: DragStartEvent) => {
+ const id = String(event.active.id);
+ setActiveEntry(entries.find((entry) => entry.id === id) ?? null);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ setActiveEntry(null);
+
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = entryIds.indexOf(String(active.id));
+ const newIndex = entryIds.indexOf(String(over.id));
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ setEntries((current) =>
+ remapEntryIndices(arrayMove([...current], oldIndex, newIndex)),
+ );
+ suppressClickRef.current = true;
+ requestAnimationFrame(() => {
+ suppressClickRef.current = false;
+ });
+ onReorder(oldIndex, newIndex);
+ };
+
+ const handleDragCancel = () => {
+ setActiveEntry(null);
+ };
+
+ const handleSelect = (index: number) => {
+ if (suppressClickRef.current) return;
+ onSelect(index);
+ };
+
return (
@@ -171,19 +349,49 @@ export function SectionVariantList({
-
- {variants.map((variant) => (
- onSelect(variant.index)}
- onDuplicate={() => onDuplicate(variant.index)}
- onDelete={() => onDelete(variant.index)}
- />
- ))}
-
+
+
+
+ {entries.map((entry) => (
+ handleSelect(entry.index)}
+ onDuplicate={() => onDuplicate(entry.index)}
+ onDelete={() => onDelete(entry.index)}
+ />
+ ))}
+
+
+
+
+ {activeEntry ? (
+
+
+
+ ) : null}
+
+
);
}
diff --git a/apps/mesh/src/web/components/sections-editor/section-variants.test.ts b/apps/mesh/src/web/components/sections-editor/section-variants.test.ts
index 0c6390fbd3..e851866614 100644
--- a/apps/mesh/src/web/components/sections-editor/section-variants.test.ts
+++ b/apps/mesh/src/web/components/sections-editor/section-variants.test.ts
@@ -10,6 +10,7 @@ import {
isDefaultVariantRule,
parseSectionFlagVariants,
pickVariantToKeepIndex,
+ reorderMultivariateSectionVariant,
showSection,
toggleSectionLazyRender,
unwrapVariantSectionValue,
@@ -175,6 +176,26 @@ describe("section-variants", () => {
expect(deleteMultivariateSectionVariant(updated, 0)).toBeNull();
});
+ it("reorderMultivariateSectionVariant moves a variant and preserves the rest", () => {
+ const mvObj = {
+ __resolveType: "website/flags/multivariate/section.ts",
+ variants: [
+ { value: { title: "A" } },
+ { value: { title: "B" } },
+ { value: { title: "C" } },
+ ],
+ };
+
+ const moved = reorderMultivariateSectionVariant(mvObj, 0, 2) as {
+ variants: Array<{ value: { title: string } }>;
+ };
+ expect(moved.variants.map((v) => v.value.title)).toEqual(["B", "C", "A"]);
+
+ // out-of-range / no-op returns the original object
+ expect(reorderMultivariateSectionVariant(mvObj, 1, 1)).toBe(mvObj);
+ expect(reorderMultivariateSectionVariant(mvObj, 0, 9)).toBe(mvObj);
+ });
+
it("isDefaultVariantRule treats always matchers as default", () => {
expect(isDefaultVariantRule(undefined)).toBe(false);
expect(isDefaultVariantRule({})).toBe(true);
diff --git a/apps/mesh/src/web/components/sections-editor/section-variants.ts b/apps/mesh/src/web/components/sections-editor/section-variants.ts
index bfc91d2315..0a4271fdba 100644
--- a/apps/mesh/src/web/components/sections-editor/section-variants.ts
+++ b/apps/mesh/src/web/components/sections-editor/section-variants.ts
@@ -341,6 +341,30 @@ export function duplicateMultivariateSectionVariant(
return { ...mvObj, variants };
}
+export function reorderMultivariateSectionVariant(
+ mvObj: Record,
+ fromIndex: number,
+ toIndex: number,
+): Record {
+ const variants = [
+ ...((mvObj.variants as Array>) ?? []),
+ ];
+ if (
+ fromIndex === toIndex ||
+ fromIndex < 0 ||
+ toIndex < 0 ||
+ fromIndex >= variants.length ||
+ toIndex >= variants.length
+ ) {
+ return mvObj;
+ }
+
+ const [moved] = variants.splice(fromIndex, 1);
+ if (!moved) return mvObj;
+ variants.splice(toIndex, 0, moved);
+ return { ...mvObj, variants };
+}
+
export function deleteMultivariateSectionVariant(
mvObj: Record,
variantIndex: number,
diff --git a/apps/mesh/src/web/components/sections-editor/sections-editor.tsx b/apps/mesh/src/web/components/sections-editor/sections-editor.tsx
index 144f32d431..b84a38f27e 100644
--- a/apps/mesh/src/web/components/sections-editor/sections-editor.tsx
+++ b/apps/mesh/src/web/components/sections-editor/sections-editor.tsx
@@ -76,6 +76,7 @@ import {
buildPageSectionsFromVariants,
countSavedMatcherBlockReferences,
getLastVariantIndex,
+ isMultivariateArrayWrapper,
parsePageVariants,
type PageVariant,
} from "./page-variants";
@@ -94,12 +95,18 @@ import {
hideSection,
parseSectionFlagVariants,
rebuildSectionWithMultivariate,
+ reorderMultivariateSectionVariant,
showSection,
toggleSectionLazyRender,
unwrapVariantSectionValue,
updateMultivariateSectionVariantRule,
updateMultivariateSectionVariantValue,
} from "./section-variants";
+import {
+ buildPageVariantOverrideParams,
+ buildSectionVariantOverrideParams,
+ type PageVariantInfo,
+} from "./variant-matcher-override";
import { PageJsonDialog } from "./page-json-dialog";
import { createReferencedBlockSaver } from "./save-referenced-block";
@@ -440,6 +447,7 @@ export function SectionsEditor({
initialEditSeo = false,
onExitSeo,
onViewJsonFile,
+ onVariantPreviewOverride,
}: {
orgSlug: string;
virtualMcpId: string;
@@ -469,6 +477,13 @@ export function SectionsEditor({
* Hosts without a file surface (Content tab) omit this and get the modal.
*/
onViewJsonFile?: (pageKey: string) => void;
+ /**
+ * Called when the selected section variant changes so the host can force the
+ * preview iframe to render that variant via `x-deco-matchers-override`.
+ * Passes `null` when no variant override should be applied (non-multivariate
+ * section, or nothing selected).
+ */
+ onVariantPreviewOverride?: (params: string[] | null) => void;
}) {
const previewFetchParams = previewReady
? { orgSlug, virtualMcpId, branch }
@@ -725,6 +740,9 @@ export function SectionsEditor({
const activeVariant = pageVariants[safeVariantIndex];
const rawSections: RawSection[] = activeVariant?.sections ?? [];
const parsedSections = parseSections(rawSections, decofile);
+ // Page is page-level multivariate when `sections` is a multivariate wrapper.
+ const pageIsMultivariate =
+ !isGlobalBlockMode && isMultivariateArrayWrapper(pageData?.sections);
// Global blocks open directly into the section form (single saved block).
if (
@@ -765,10 +783,78 @@ export function SectionsEditor({
selectedSectionIndex,
};
+ // Force the preview iframe to render the selected variant via the deco
+ // runtime's `x-deco-matchers-override`. `null` clears any prior override.
+ const currentPageVariantInfo = (): PageVariantInfo => ({
+ multivariate: pageIsMultivariate,
+ index: safeVariantIndex,
+ variants: pageVariants.map((v) => ({ rule: v.rule })),
+ });
+
+ // Force the preview to render a specific *page* variant. Pass the target
+ // index/variants explicitly so callers can drive the override before the
+ // render-scope `safeVariantIndex` reflects a pending state change.
+ const emitPageVariantOverride = (
+ variants: Array<{ rule?: Record }>,
+ index: number,
+ ) => {
+ if (!onVariantPreviewOverride) return;
+ if (!activePageKey || !pageIsMultivariate || variants.length <= 1) {
+ onVariantPreviewOverride(null);
+ return;
+ }
+ const params = buildPageVariantOverrideParams(
+ activePageKey,
+ { multivariate: true, index, variants },
+ decofile,
+ meta,
+ );
+ onVariantPreviewOverride(params.length > 0 ? params : null);
+ };
+
+ const syncVariantPreviewOverride = (
+ mvObj: Record | null,
+ sectionIndex: number,
+ variantIndex: number,
+ ) => {
+ if (!onVariantPreviewOverride) return;
+ if (!activePageKey) {
+ onVariantPreviewOverride(null);
+ return;
+ }
+ const pageInfo = currentPageVariantInfo();
+ const pageParams = buildPageVariantOverrideParams(
+ activePageKey,
+ pageInfo,
+ decofile,
+ meta,
+ );
+ if (!mvObj || sectionIndex < 0) {
+ onVariantPreviewOverride(pageParams.length > 0 ? pageParams : null);
+ return;
+ }
+ const sectionParams = buildSectionVariantOverrideParams({
+ pageKey: activePageKey,
+ page: pageInfo,
+ sectionIndex,
+ sectionLazy: parsedSections[sectionIndex]?.isLazy ?? false,
+ mvObj,
+ selectedVariantIndex: variantIndex,
+ decofile,
+ meta,
+ });
+ const params = [...pageParams, ...sectionParams];
+ onVariantPreviewOverride(params.length > 0 ? params : null);
+ };
+
const applySectionVariant = (
rawSection: RawSection,
parsed: ParsedSection,
variantIndex: number,
+ sectionIndex: number = selectedSectionIndex ?? -1,
+ // Only force the preview to this variant on an explicit variant-row click.
+ // Merely entering/auto-selecting a section must not re-navigate the iframe.
+ syncPreview = false,
) => {
const mvObj = getMultivariateSectionObject(rawSection, parsed);
if (!mvObj) {
@@ -776,6 +862,8 @@ export function SectionsEditor({
setActiveResolveType(null);
setSectionRuleResolveType(null);
setSectionRuleFormValue(null);
+ if (syncPreview)
+ syncVariantPreviewOverride(null, sectionIndex, variantIndex);
return;
}
@@ -786,6 +874,8 @@ export function SectionsEditor({
setActiveResolveType(null);
setSectionRuleResolveType(null);
setSectionRuleFormValue(null);
+ if (syncPreview)
+ syncVariantPreviewOverride(null, sectionIndex, variantIndex);
return;
}
@@ -800,6 +890,8 @@ export function SectionsEditor({
);
setSectionRuleResolveType(resolveType);
setSectionRuleFormValue(formValue);
+ if (syncPreview)
+ syncVariantPreviewOverride(mvObj, sectionIndex, variantIndex);
};
// Auto-select section when parent signals a click-through from the preview.
@@ -817,7 +909,7 @@ export function SectionsEditor({
setFieldBreadcrumbs([]);
setFormResetKey((key) => key + 1);
if (parsed.isMultivariate) {
- applySectionVariant(rawSection, parsed, 0);
+ applySectionVariant(rawSection, parsed, 0, externalSelectedIndex);
} else {
const unwrapped = unwrapSection(rawSection, parsed, decofile);
if (!unwrapped) {
@@ -1007,7 +1099,7 @@ export function SectionsEditor({
if (!rawSection || !parsed) return;
if (parsed.isMultivariate) {
- applySectionVariant(rawSection, parsed, 0);
+ applySectionVariant(rawSection, parsed, 0, index);
return;
}
@@ -1017,6 +1109,7 @@ export function SectionsEditor({
setActiveResolveType(null);
setSectionRuleResolveType(null);
setSectionRuleFormValue(null);
+ syncVariantPreviewOverride(null, index, 0);
return;
}
@@ -1024,6 +1117,7 @@ export function SectionsEditor({
setActiveResolveType(unwrapped.resolveType);
setSectionRuleResolveType(null);
setSectionRuleFormValue(null);
+ syncVariantPreviewOverride(null, index, 0);
};
const handleFormChange = (val: unknown) => {
@@ -1242,7 +1336,13 @@ export function SectionsEditor({
setActiveSectionVariantIndex(variantIndex);
setFieldBreadcrumbs([]);
setFormResetKey((key) => key + 1);
- applySectionVariant(rawSection, parsed, variantIndex);
+ applySectionVariant(
+ rawSection,
+ parsed,
+ variantIndex,
+ selectedSectionIndex,
+ true,
+ );
};
const handleDeleteSectionVariant = (variantIndex: number) => {
@@ -1318,6 +1418,54 @@ export function SectionsEditor({
savePageSections(updatedSections);
};
+ const handleReorderSectionVariant = (fromIndex: number, toIndex: number) => {
+ if (selectedSectionIndex === null || !activePageKey) return;
+ if (fromIndex === toIndex) return;
+ const rawSection = rawSections[selectedSectionIndex];
+ const parsed = parsedSections[selectedSectionIndex];
+ if (!rawSection || !parsed?.isMultivariate) return;
+
+ const mvObj = getMultivariateSectionObject(rawSection, parsed);
+ if (!mvObj) return;
+
+ const updatedMvObj = reorderMultivariateSectionVariant(
+ mvObj,
+ fromIndex,
+ toIndex,
+ );
+
+ const updatedSections = [...rawSections];
+ updatedSections[selectedSectionIndex] = rebuildSectionWithMultivariate(
+ rawSection,
+ parsed,
+ updatedMvObj,
+ );
+
+ // Keep the selected variant pointing at the same variant after the move.
+ let nextIndex = activeSectionVariantIndex;
+ if (activeSectionVariantIndex === fromIndex) {
+ nextIndex = toIndex;
+ } else if (
+ fromIndex < activeSectionVariantIndex &&
+ toIndex >= activeSectionVariantIndex
+ ) {
+ nextIndex = activeSectionVariantIndex - 1;
+ } else if (
+ fromIndex > activeSectionVariantIndex &&
+ toIndex <= activeSectionVariantIndex
+ ) {
+ nextIndex = activeSectionVariantIndex + 1;
+ }
+
+ setActiveSectionVariantIndex(nextIndex);
+ applySectionVariant(
+ updatedSections[selectedSectionIndex],
+ parsed,
+ nextIndex,
+ );
+ savePageSections(updatedSections);
+ };
+
const handleAddSectionVariant = (index?: number) => {
const sectionIndex = index ?? selectedSectionIndex;
if (sectionIndex === null || !activePageKey) return;
@@ -1344,7 +1492,12 @@ export function SectionsEditor({
setActiveSectionVariantIndex(result.newVariantIndex);
setFieldBreadcrumbs([]);
setFormResetKey((key) => key + 1);
- applySectionVariant(result.section, nextParsed, result.newVariantIndex);
+ applySectionVariant(
+ result.section,
+ nextParsed,
+ result.newVariantIndex,
+ sectionIndex,
+ );
savePageSections(updatedSections);
};
@@ -1386,6 +1539,7 @@ export function SectionsEditor({
setActiveResolveType(unwrapped.resolveType);
}
+ syncVariantPreviewOverride(null, selectedSectionIndex, 0);
savePageSections(updatedSections);
};
@@ -1835,18 +1989,32 @@ export function SectionsEditor({
);
setRuleResolveType(resolveType);
setRuleFormValue(formValue);
+ emitPageVariantOverride(
+ pageVariants.map((v) => ({ rule: v.rule })),
+ index,
+ );
};
const handleReorderPageVariants = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
+ let nextIndex = safeVariantIndex;
if (safeVariantIndex === fromIndex) {
- setActiveVariantIndex(toIndex);
+ nextIndex = toIndex;
} else if (fromIndex < safeVariantIndex && toIndex >= safeVariantIndex) {
- setActiveVariantIndex(safeVariantIndex - 1);
+ nextIndex = safeVariantIndex - 1;
} else if (fromIndex > safeVariantIndex && toIndex <= safeVariantIndex) {
- setActiveVariantIndex(safeVariantIndex + 1);
+ nextIndex = safeVariantIndex + 1;
}
+ setActiveVariantIndex(nextIndex);
+
+ // Keep the preview override pointing at the same variant after the move.
+ const reorderedRules = arrayMove(
+ pageVariants.map((v) => ({ rule: v.rule })),
+ fromIndex,
+ toIndex,
+ );
+ emitPageVariantOverride(reorderedRules, nextIndex);
mutatePageVariants((variants) => arrayMove(variants, fromIndex, toIndex));
};
@@ -2286,6 +2454,7 @@ export function SectionsEditor({
{isEditingMultivariateSection && sectionFlagVariants.length > 0 && (
<>
({
index,
label: variant.label,
@@ -2295,6 +2464,7 @@ export function SectionsEditor({
onDuplicate={handleDuplicateSectionVariant}
onDelete={handleDeleteSectionVariant}
onRemoveAll={handleRemoveAllSectionVariants}
+ onReorder={handleReorderSectionVariant}
onAdd={() => handleAddSectionVariant()}
/>
{isEditingMultivariateSection && (
diff --git a/apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts
new file mode 100644
index 0000000000..b57a8e219b
--- /dev/null
+++ b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from "bun:test";
+import {
+ buildPageVariantOverrideParams,
+ buildSectionVariantOverrideParams,
+ MATCHER_OVERRIDE_QS,
+ type PageVariantInfo,
+ withVariantMatcherOverride,
+} from "./variant-matcher-override";
+
+const ALWAYS = { __resolveType: "website/matchers/always.ts" };
+const inline = (rt: string) => ({ __resolveType: rt });
+
+function mv(...rules: Array>): Record {
+ return {
+ __resolveType: "website/flags/multivariate/section.ts",
+ variants: rules.map((rule) => ({
+ value: { __resolveType: "site/sections/Hero.tsx" },
+ rule,
+ })),
+ };
+}
+
+const SINGLE_PAGE: PageVariantInfo = {
+ multivariate: false,
+ index: 0,
+ variants: [{ rule: ALWAYS }],
+};
+
+describe("buildSectionVariantOverrideParams", () => {
+ it("forces the selected variant on and earlier variants off", () => {
+ const params = buildSectionVariantOverrideParams({
+ pageKey: "pages-Home-1",
+ page: SINGLE_PAGE,
+ sectionIndex: 2,
+ sectionLazy: false,
+ mvObj: mv(ALWAYS, inline("website/matchers/device.ts"), ALWAYS),
+ selectedVariantIndex: 1,
+ decofile: {},
+ meta: null,
+ });
+
+ expect(params).toEqual([
+ "pages-Home-1@sections.2.variants.0.rule=0",
+ "pages-Home-1@sections.2.variants.1.rule=1",
+ ]);
+ });
+
+ it("inserts `.section` for lazy-wrapped sections", () => {
+ const params = buildSectionVariantOverrideParams({
+ pageKey: "pages-home-c4bcbfb771e9",
+ page: SINGLE_PAGE,
+ sectionIndex: 9,
+ sectionLazy: true,
+ mvObj: mv(ALWAYS, ALWAYS),
+ selectedVariantIndex: 0,
+ decofile: {},
+ meta: null,
+ });
+
+ expect(params).toEqual([
+ "pages-home-c4bcbfb771e9@sections.9.section.variants.0.rule=1",
+ ]);
+ });
+
+ it("nests under the active page variant for multivariate pages", () => {
+ const params = buildSectionVariantOverrideParams({
+ pageKey: "pages-Home-1",
+ page: { multivariate: true, index: 2, variants: [{}, {}, {}] },
+ sectionIndex: 4,
+ sectionLazy: false,
+ mvObj: mv(ALWAYS, ALWAYS),
+ selectedVariantIndex: 1,
+ decofile: {},
+ meta: null,
+ });
+
+ expect(params).toEqual([
+ "pages-Home-1@sections.variants.2.value.4.variants.0.rule=0",
+ "pages-Home-1@sections.variants.2.value.4.variants.1.rule=1",
+ ]);
+ });
+
+ it("uses the block resolveType as id for saved matcher block references", () => {
+ const savedKey = "matchers-Black-Friday-abc";
+ const decofile = {
+ [savedKey]: { __resolveType: "website/matchers/date.ts", name: "BF" },
+ };
+ const params = buildSectionVariantOverrideParams({
+ pageKey: "pages-Home-1",
+ page: SINGLE_PAGE,
+ sectionIndex: 1,
+ sectionLazy: false,
+ mvObj: mv(ALWAYS, inline(savedKey)),
+ selectedVariantIndex: 1,
+ decofile,
+ meta: null,
+ });
+
+ expect(params).toEqual([
+ "pages-Home-1@sections.1.variants.0.rule=0",
+ `${savedKey}=1`,
+ ]);
+ });
+});
+
+describe("buildPageVariantOverrideParams", () => {
+ it("returns [] for a non-multivariate page", () => {
+ expect(
+ buildPageVariantOverrideParams("pages-Home-1", SINGLE_PAGE, {}, null),
+ ).toEqual([]);
+ });
+
+ it("forces the active page variant", () => {
+ const params = buildPageVariantOverrideParams(
+ "pages-Home-1",
+ { multivariate: true, index: 1, variants: [{ rule: ALWAYS }, {}, {}] },
+ {},
+ null,
+ );
+ expect(params).toEqual([
+ "pages-Home-1@sections.variants.0.rule=0",
+ "pages-Home-1@sections.variants.1.rule=1",
+ ]);
+ });
+});
+
+describe("withVariantMatcherOverride", () => {
+ it("appends each param as a repeated query key", () => {
+ const href = withVariantMatcherOverride(
+ "https://site.example.com/?deviceHint=desktop",
+ ["a@sections.0.variants.0.rule=0", "a@sections.0.variants.1.rule=1"],
+ );
+ const url = new URL(href);
+ expect(url.searchParams.getAll(MATCHER_OVERRIDE_QS)).toEqual([
+ "a@sections.0.variants.0.rule=0",
+ "a@sections.0.variants.1.rule=1",
+ ]);
+ expect(url.searchParams.get("deviceHint")).toBe("desktop");
+ });
+
+ it("returns the href unchanged when there are no params", () => {
+ const href = "https://site.example.com/?deviceHint=desktop";
+ expect(withVariantMatcherOverride(href, [])).toBe(href);
+ });
+
+ it("replaces any pre-existing override params", () => {
+ const href = withVariantMatcherOverride(
+ `https://site.example.com/?${MATCHER_OVERRIDE_QS}=stale=1`,
+ ["fresh=1"],
+ );
+ const url = new URL(href);
+ expect(url.searchParams.getAll(MATCHER_OVERRIDE_QS)).toEqual(["fresh=1"]);
+ });
+});
diff --git a/apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts
new file mode 100644
index 0000000000..6bd1b88c35
--- /dev/null
+++ b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts
@@ -0,0 +1,173 @@
+import {
+ getSavedMatcherBlockKey,
+ isSavedMatcherBlockReference,
+} from "./matcher-rules";
+import type { LiveMeta } from "./resolve-schema";
+
+/**
+ * Query-param name the deco runtime reads to force matcher activation.
+ * See `@deco/deco` `blocks/matcher.ts` (`x-deco-matchers-override`).
+ */
+export const MATCHER_OVERRIDE_QS = "x-deco-matchers-override";
+
+/**
+ * Page-level variant context. A page's `sections` is either a plain array
+ * (single variant) or a multivariate wrapper (`{ variants: [{ rule, value }] }`).
+ */
+export interface PageVariantInfo {
+ /** `page.sections` is a multivariate wrapper (page-level variants exist). */
+ multivariate: boolean;
+ /** Active page variant index (0 when not multivariate). */
+ index: number;
+ /** Page variant entries — only each entry's `rule` is read. */
+ variants: Array<{ rule?: Record }>;
+}
+
+interface OverrideParamsArgs {
+ pageKey: string;
+ /**
+ * Dotted resolve-chain path from the page resolvable up to (and including)
+ * the `variants` array that holds the matcher rules — e.g.
+ * `sections.variants` for page variants, or
+ * `sections.3.section.variants` for a lazy section's variants.
+ */
+ prefix: string;
+ variants: Array<{ rule?: Record }>;
+ selectedIndex: number;
+ decofile: Record;
+ meta?: LiveMeta | null;
+}
+
+/**
+ * Build `"=<1|0>"` override values for one multivariate array.
+ *
+ * The deco runtime (`blocks/matcher.ts`) computes each matcher's unique id by
+ * walking the resolve chain back to the nearest *named resolvable* (a decofile
+ * key), skipping resolver/dangling entries, joining `prop` names with `.` and
+ * prefixing the resolvable with `@`. So an inline matcher's id is
+ * `@..rule`; a *saved matcher block* reference is itself
+ * a named resolvable, so its id is just the block key.
+ *
+ * For the selected index the matcher is forced on (`=1`) and earlier ones off
+ * (`=0`); the runtime renders the first matching entry, so later ones are left
+ * untouched.
+ */
+function overrideParamsForVariants({
+ pageKey,
+ prefix,
+ variants,
+ selectedIndex,
+ decofile,
+ meta,
+}: OverrideParamsArgs): string[] {
+ if (!pageKey || selectedIndex < 0 || selectedIndex >= variants.length) {
+ return [];
+ }
+
+ const params: string[] = [];
+ for (let idx = 0; idx <= selectedIndex; idx++) {
+ const variant = variants[idx];
+ if (!variant) continue;
+ const rule = variant.rule as Record | undefined;
+ const activation = idx === selectedIndex ? "1" : "0";
+
+ const savedBlockKey = isSavedMatcherBlockReference(rule, decofile, meta)
+ ? getSavedMatcherBlockKey(rule, decofile, meta)
+ : null;
+
+ const id = savedBlockKey ?? `${pageKey}@${prefix}.${idx}.rule`;
+ params.push(`${id}=${activation}`);
+ }
+
+ return params;
+}
+
+/**
+ * Force the preview to render the active *page* variant. Returns `[]` when the
+ * page is not multivariate (nothing to force).
+ */
+export function buildPageVariantOverrideParams(
+ pageKey: string,
+ page: PageVariantInfo,
+ decofile: Record,
+ meta?: LiveMeta | null,
+): string[] {
+ if (!page.multivariate || page.variants.length <= 1) return [];
+ return overrideParamsForVariants({
+ pageKey,
+ prefix: "sections.variants",
+ variants: page.variants,
+ selectedIndex: page.index,
+ decofile,
+ meta,
+ });
+}
+
+export interface SectionVariantOverrideArgs {
+ pageKey: string;
+ page: PageVariantInfo;
+ /** Index of the multivariate section within the active page variant's sections. */
+ sectionIndex: number;
+ /** The section is wrapped in a Lazy block (adds a `.section` chain segment). */
+ sectionLazy: boolean;
+ /** The multivariate section object (`{ variants: [{ value, rule }] }`). */
+ mvObj: Record;
+ selectedVariantIndex: number;
+ decofile: Record;
+ meta?: LiveMeta | null;
+}
+
+/**
+ * Force the preview to render the selected *section* variant. The resolve-chain
+ * prefix accounts for page-level variant nesting (`sections.variants..value`)
+ * and Lazy wrapping (`.section`).
+ */
+export function buildSectionVariantOverrideParams({
+ pageKey,
+ page,
+ sectionIndex,
+ sectionLazy,
+ mvObj,
+ selectedVariantIndex,
+ decofile,
+ meta,
+}: SectionVariantOverrideArgs): string[] {
+ if (sectionIndex < 0) return [];
+
+ const sectionBase = page.multivariate
+ ? `sections.variants.${page.index}.value`
+ : "sections";
+ const prefix = `${sectionBase}.${sectionIndex}${
+ sectionLazy ? ".section" : ""
+ }.variants`;
+
+ const variants = Array.isArray(mvObj.variants)
+ ? (mvObj.variants as Array<{ rule?: Record }>)
+ : [];
+
+ return overrideParamsForVariants({
+ pageKey,
+ prefix,
+ variants,
+ selectedIndex: selectedVariantIndex,
+ decofile,
+ meta,
+ });
+}
+
+/** Append override params to a preview URL, returning the new href. */
+export function withVariantMatcherOverride(
+ href: string,
+ params: string[],
+): string {
+ if (params.length === 0) return href;
+ const url = new URL(href, "http://local");
+ url.searchParams.delete(MATCHER_OVERRIDE_QS);
+ for (const param of params) {
+ url.searchParams.append(MATCHER_OVERRIDE_QS, param);
+ }
+ // Preserve relative hrefs when the input had no origin.
+ return href.startsWith("http")
+ ? url.toString()
+ : `${url.pathname}${url.search}${url.hash}`;
+}