diff --git a/prototype/canvas-layer-controls.html b/prototype/canvas-layer-controls.html
index e34e544..0632c13 100644
--- a/prototype/canvas-layer-controls.html
+++ b/prototype/canvas-layer-controls.html
@@ -353,6 +353,29 @@
Adapts when reused
return "";
}
+ // Locking a layer's stated purpose is "so it cannot move by accident" (see the summary
+ // note above the list). That promise covers two paths: the locked layer's own up/down
+ // controls, and an unlocked neighbour's move that would swap positions with it. A single
+ // up/down step only ever swaps with the adjacent layer, so checking that one neighbour is
+ // enough to decide whether a move is allowed.
+ function canMoveLayer(list, index, delta) {
+ const target = index + delta;
+ if (target < 0 || target >= list.length) { return false; }
+ const layer = list[index];
+ const neighbor = list[target];
+ if (!layer || !neighbor) { return false; }
+ return !layer.locked && !neighbor.locked;
+ }
+
+ function reorderLayers(list, index, delta) {
+ if (!canMoveLayer(list, index, delta)) { return list; }
+ const target = index + delta;
+ const copy = list.slice();
+ const item = copy.splice(index, 1)[0];
+ copy.splice(target, 0, item);
+ return copy;
+ }
+
function renderLayer(layer, index) {
const type = LAYER_TYPES[layer.type];
@@ -368,10 +391,10 @@ Adapts when reused
const main = el("div", { class: "layer-main" }, [nameRow, meta]);
const up = el("button", { type: "button", class: "tiny secondary", "aria-label": "Move " + type.label + " up", text: "▲" }, []);
- if (index === 0) { up.disabled = true; }
+ if (!canMoveLayer(layers, index, -1)) { up.disabled = true; }
up.addEventListener("click", function () { move(index, -1); });
const down = el("button", { type: "button", class: "tiny secondary", "aria-label": "Move " + type.label + " down", text: "▼" }, []);
- if (index === layers.length - 1) { down.disabled = true; }
+ if (!canMoveLayer(layers, index, 1)) { down.disabled = true; }
down.addEventListener("click", function () { move(index, 1); });
const vis = el("button", { type: "button", class: "tiny secondary", text: layer.visible ? "Hide" : "Show" }, []);
@@ -403,17 +426,20 @@ Adapts when reused
}
function move(index, delta) {
- const target = index + delta;
- if (target < 0 || target >= layers.length) { return; }
- const copy = layers.slice();
- const item = copy.splice(index, 1)[0];
- copy.splice(target, 0, item);
- layers = copy;
+ const next = reorderLayers(layers, index, delta);
+ if (next === layers) { return; }
+ layers = next;
render();
}
if (typeof module !== "undefined" && module.exports) {
- module.exports = { evaluate: evaluate, LAYER_TYPES: LAYER_TYPES, layoutSignature: layoutSignature };
+ module.exports = {
+ evaluate: evaluate,
+ LAYER_TYPES: LAYER_TYPES,
+ layoutSignature: layoutSignature,
+ canMoveLayer: canMoveLayer,
+ reorderLayers: reorderLayers,
+ };
}
render();
diff --git a/prototype/canvas-layer-controls.test.js b/prototype/canvas-layer-controls.test.js
index 6b69e3e..d0d373c 100644
--- a/prototype/canvas-layer-controls.test.js
+++ b/prototype/canvas-layer-controls.test.js
@@ -122,4 +122,44 @@ if (typeof M.layoutSignature === "function") {
);
}
-console.log("canvas layer controls: stack guardrails (cover, lock, hidden speaker) verified");
+// Locking a layer's stated purpose is "so it cannot move by accident" (see the lede /
+// summary-note copy). That must hold both when the locked layer's own controls are used and
+// when an unlocked neighbour's move would swap positions with it.
+assert.ok(typeof M.canMoveLayer === "function", "prototype exports canMoveLayer()");
+assert.ok(typeof M.reorderLayers === "function", "prototype exports reorderLayers()");
+
+const lockedStack = [
+ { id: "captions", type: "captions", visible: true, locked: false },
+ { id: "brand", type: "brand", visible: true, locked: false },
+ { id: "background", type: "background", visible: true, locked: true },
+];
+
+assert.strictEqual(M.canMoveLayer(lockedStack, 2, -1), false, "the locked layer itself cannot move up");
+assert.strictEqual(M.canMoveLayer(lockedStack, 1, 1), false, "an unlocked neighbour cannot displace the locked layer by moving down");
+assert.strictEqual(M.canMoveLayer(lockedStack, 0, 1), true, "two unlocked layers may still swap with each other");
+
+assert.strictEqual(
+ M.reorderLayers(lockedStack, 2, -1),
+ lockedStack,
+ "moving a locked layer is refused and returns the same list reference",
+);
+assert.strictEqual(
+ M.reorderLayers(lockedStack, 1, 1),
+ lockedStack,
+ "moving an unlocked layer into a locked neighbour is refused",
+);
+
+const reordered = M.reorderLayers(lockedStack, 0, 1);
+assert.notStrictEqual(reordered, lockedStack, "an allowed reorder returns a new list");
+assert.deepStrictEqual(
+ reordered.map((layer) => layer.id),
+ ["brand", "captions", "background"],
+ "an allowed reorder swaps the requested neighbour",
+);
+assert.deepStrictEqual(
+ lockedStack.map((layer) => layer.id),
+ ["captions", "brand", "background"],
+ "reorderLayers does not mutate its input list",
+);
+
+console.log("canvas layer controls: stack guardrails (cover, lock, hidden speaker, locked reorder) verified");