diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts
index 51803a8..6a51259 100644
--- a/mock/e2e/phy.spec.ts
+++ b/mock/e2e/phy.spec.ts
@@ -706,3 +706,37 @@ test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに
expect(Math.abs(w! - box.width)).toBeLessThan(1);
expect(Math.abs(h! - box.height)).toBeLessThan(1);
});
+
+test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
+ const key = page.locator('[data-node-type="key"]').first();
+ const id = await key.getAttribute("data-node-id");
+ await expect(page.locator(".mount-layer")).toHaveCount(0);
+ await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
+ const mark = page.locator(`[data-mount-id="${id}"]`);
+ await expect(mark.locator(".switch")).toHaveCount(1);
+ await expect(mark.locator(".stab")).toHaveCount(0);
+
+ await key.click();
+ await expect(page.locator(".stabilizers")).toContainText("候補なし");
+ await page.getByLabel("Width").fill("6.25");
+ await page.getByLabel("Width").blur();
+ for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
+ await page.getByLabel("±50mm").check();
+ await page.getByLabel("±40mm").check();
+ await expect(mark.locator(".stab")).toHaveCount(4);
+
+ const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
+ const before = await switchX();
+ await page.getByLabel("switchOffset X").fill("0.625");
+ await page.getByLabel("switchOffset X").blur();
+ await expect.poll(switchX).toBeGreaterThan(before + 10);
+ await expect(mark.locator(".stab")).toHaveCount(4);
+
+ await page.getByRole("button", { name: "Undo" }).click();
+ await page.getByRole("button", { name: "Undo" }).click();
+ await expect(mark.locator(".stab")).toHaveCount(2);
+ await expect(page.getByLabel("±40mm")).not.toBeChecked();
+
+ await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
+ await expect(page.locator(".mount-layer")).toHaveCount(0);
+});
diff --git a/mock/src/domain/stabilizers.ts b/mock/src/domain/stabilizers.ts
new file mode 100644
index 0000000..7daefc1
--- /dev/null
+++ b/mock/src/domain/stabilizers.ts
@@ -0,0 +1,73 @@
+import type { Point, Size } from "./phy";
+
+/** One candidate stabilizer layout: two mounts at ±offset along the key's long axis, in U. */
+export type StabilizerCandidate = { id: string; label: string; offset: number };
+
+const MM = 1 / 19.05;
+/** Cherry MX spacings by key length. provisional/decisions.md「単位8のスタビ候補」. */
+const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
+ {
+ lengths: [2, 2.25, 2.5, 2.75],
+ candidates: [{ id: "11.938mm", label: "±11.938mm", offset: 11.938 * MM }],
+ },
+ { lengths: [3], candidates: [{ id: "1u", label: "±1U", offset: 1 }] },
+ { lengths: [4], candidates: [{ id: "1.5u", label: "±1.5U", offset: 1.5 }] },
+ { lengths: [4.5], candidates: [{ id: "34.671mm", label: "±34.671mm", offset: 34.671 * MM }] },
+ { lengths: [5.5], candidates: [{ id: "2.25u", label: "±2.25U", offset: 2.25 }] },
+ {
+ lengths: [6],
+ candidates: [
+ { id: "2.5u", label: "±2.5U", offset: 2.5 },
+ { id: "2u", label: "±2U", offset: 2 },
+ ],
+ },
+ {
+ lengths: [6.25],
+ candidates: [
+ { id: "50mm", label: "±50mm", offset: 50 * MM },
+ { id: "40mm", label: "±40mm", offset: 40 * MM },
+ { id: "42.5mm", label: "±42.5mm", offset: 42.5 * MM },
+ ],
+ },
+ { lengths: [6.5], candidates: [{ id: "2.75u", label: "±2.75U", offset: 2.75 }] },
+ { lengths: [7], candidates: [{ id: "3u", label: "±3U", offset: 3 }] },
+ { lengths: [8, 9, 9.75, 10], candidates: [{ id: "3.5u", label: "±3.5U", offset: 3.5 }] },
+];
+
+const EPS = 1e-9;
+/** Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet. */
+function irregular(size: Size): boolean {
+ return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
+}
+export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
+ if (irregular(size)) return [];
+ const length = Math.max(size.w, size.h);
+ return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
+}
+/** A size with a single candidate shows it until chosen otherwise; several start unselected. */
+export function defaultStabilizerSelection(size: Size): string[] {
+ const candidates = stabilizerCandidates(size);
+ return candidates.length === 1 ? [candidates[0]!.id] : [];
+}
+/** Mount points in key-local coordinates, around the (w, h) rectangle's center, not the switch. */
+export function stabilizerPoints(size: Size, ids: string[]): Point[] {
+ const cx = size.w / 2,
+ cy = size.h / 2,
+ vertical = size.h > size.w;
+ return stabilizerCandidates(size)
+ .filter((c) => ids.includes(c.id))
+ .flatMap((c) =>
+ vertical
+ ? [
+ { x: cx, y: cy - c.offset },
+ { x: cx, y: cy + c.offset },
+ ]
+ : [
+ { x: cx - c.offset, y: cy },
+ { x: cx + c.offset, y: cy },
+ ],
+ );
+}
+export function switchPoint(key: { size: Size; switchOffset?: Point }): Point {
+ return { x: key.size.w / 2 + (key.switchOffset?.x ?? 0), y: key.size.h / 2 + (key.switchOffset?.y ?? 0) };
+}
diff --git a/mock/src/ui/phy/PhyEditor.vue b/mock/src/ui/phy/PhyEditor.vue
index ce778ba..d34e6e7 100644
--- a/mock/src/ui/phy/PhyEditor.vue
+++ b/mock/src/ui/phy/PhyEditor.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+
diff --git a/mock/src/ui/phy/PhyProperties.vue b/mock/src/ui/phy/PhyProperties.vue
index fadf553..4827c24 100644
--- a/mock/src/ui/phy/PhyProperties.vue
+++ b/mock/src/ui/phy/PhyProperties.vue
@@ -1,11 +1,13 @@