feat(modifiers): persist bound layout on profile + drive Lobby layout from profile

Previously the ModifierProfileEditor's layout picker was editor-only
UI state — it drove per-instance board preview and live validation,
but was dropped on save. This meant per-instance modifiers (square-
bound to their authoring layout) silently became orphans in the Lobby
if the user picked a different layout at apply time, with no signal
about the mismatch.

Changes:

- ModifierProfileEditor now writes the bound layout through to
  profile.layoutId (schema already supported the optional field) and
  rehydrates boundLayout from profile.layoutId on open / load /
  undo / redo.
- Lobby rearranged so the Modifier Profile picker sits ABOVE the
  Layout Picker. Picking a profile with a layoutId binding snaps
  selectedLayout to match. Same behavior on URL ?modifierProfile
  deep-links and after the editor closes with a new save.
- Mismatch banner (amber) surfaces when the user overrides the
  layout after picking a bound profile, with a one-click "Switch
  to <LayoutName>" restore.

Unit test added for library round-trip of layoutId; new
profile-layout-binding.spec.ts covers the four scenarios: snap,
manual override + mismatch banner, unbound profile leaves layout
alone, and editor-save persists layoutId.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 13:28:06 -06:00
commit 4b08b0c71c
No known key found for this signature in database
4 changed files with 387 additions and 75 deletions

View file

@ -0,0 +1,193 @@
/**
* E2E — Modifier profile ⇄ layout binding.
*
* Per-instance modifiers are square-bound to the layout they were
* authored against. Rather than leaving this coupling implicit, the
* profile descriptor now carries an optional `layoutId` that:
* 1. Is persisted when the user sets a bound layout in the
* ModifierProfileEditor, AND
* 2. Drives the Lobby's selectedLayout when the profile is picked,
* AND
* 3. Surfaces a mismatch warning if the user manually overrides
* the layout after picking the profile.
*
* This spec exercises the full save → pick → auto-switch → mismatch
* loop.
*/
import { test, expect, type Page } from '@playwright/test';
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
/**
* Seed a saved profile with `layoutId` pre-bound so downstream tests
* don't have to open the editor to set it up. Mirrors the shape the
* ModifierProfileEditor would write to localStorage via saveToLibrary.
*/
async function seedBoundProfile(
page: Page,
id: string,
name: string,
layoutId: string,
): Promise<void> {
await page.evaluate(
({ key, id, name, layoutId }) => {
const entry = {
id,
name,
profile: {
id,
name,
description: '',
layoutId,
perType: [],
perInstance: [],
version: 1,
source: 'custom',
},
starred: false,
updatedAt: Date.now(),
};
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: PROFILE_LIBRARY_KEY, id, name, layoutId },
);
}
async function freshLobby(page: Page): Promise<void> {
await page.goto('/');
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.reload();
await expect(page.getByTestId('page-home')).toBeVisible();
}
test.describe('Modifier profile ⇄ layout binding', () => {
test('picking a profile with layoutId snaps the lobby layout', async ({
page,
}) => {
await freshLobby(page);
await seedBoundProfile(
page,
'lib-horde-bound',
'Horde Profile',
'horde',
);
await page.reload();
// Pre-condition: lobby starts on Classic.
await expect(page.getByTestId('layout-picker')).toHaveValue('classic');
await page.getByTestId('profile-picker').selectOption('lib-horde-bound');
// Auto-switch: LayoutPicker snaps to the profile's authored layout.
await expect(page.getByTestId('layout-picker')).toHaveValue('horde');
// No mismatch banner should appear because the snap matches.
await expect(
page.getByTestId('layout-profile-mismatch'),
).toHaveCount(0);
});
test('mismatch banner appears when user overrides layout after picking bound profile', async ({
page,
}) => {
await freshLobby(page);
await seedBoundProfile(
page,
'lib-horde-bound',
'Horde Profile',
'horde',
);
await page.reload();
await page.getByTestId('profile-picker').selectOption('lib-horde-bound');
await expect(page.getByTestId('layout-picker')).toHaveValue('horde');
// User overrides to a different layout — mismatch surfaces.
await page.getByTestId('layout-picker').selectOption('classic');
const banner = page.getByTestId('layout-profile-mismatch');
await expect(banner).toBeVisible();
await expect(banner).toContainText('Horde Profile');
// Clicking "Switch to …" restores the profile's authored layout.
await page.getByTestId('layout-profile-mismatch-switch').click();
await expect(page.getByTestId('layout-picker')).toHaveValue('horde');
await expect(
page.getByTestId('layout-profile-mismatch'),
).toHaveCount(0);
});
test('profile without layoutId leaves the layout unchanged', async ({
page,
}) => {
await freshLobby(page);
// Profile without a layoutId.
await page.evaluate(
({ key }) => {
const entry = {
id: 'lib-unbound',
name: 'Unbound Profile',
profile: {
id: 'lib-unbound',
name: 'Unbound Profile',
description: '',
perType: [],
perInstance: [],
version: 1,
source: 'custom',
},
starred: false,
updatedAt: Date.now(),
};
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: PROFILE_LIBRARY_KEY },
);
await page.reload();
// Pick some non-default layout first.
await page.getByTestId('layout-picker').selectOption('horde');
await expect(page.getByTestId('layout-picker')).toHaveValue('horde');
// Picking an unbound profile should NOT steal the layout.
await page.getByTestId('profile-picker').selectOption('lib-unbound');
await expect(page.getByTestId('layout-picker')).toHaveValue('horde');
await expect(
page.getByTestId('layout-profile-mismatch'),
).toHaveCount(0);
});
test('editor persists the bound layout on save', async ({ page }) => {
await freshLobby(page);
// Open the editor via the picker's "Custom…" option.
await page.getByTestId('profile-picker').selectOption('custom');
await expect(
page.getByTestId('modifier-editor-modal'),
).toBeVisible({ timeout: 3000 });
// Set a name and bind the layout.
await page.getByTestId('profile-name').fill('My Bound Test');
await page.getByTestId('bound-layout-picker').selectOption('horde');
// Save + close the editor.
await page.getByTestId('save-profile').click();
await page.keyboard.press('Escape');
// Confirm the library entry carries layoutId.
const stored = await page.evaluate(
({ key }) => localStorage.getItem(key),
{ key: PROFILE_LIBRARY_KEY },
);
expect(stored).not.toBeNull();
const entries = JSON.parse(stored as string) as {
profile: { layoutId?: string; name: string };
}[];
const mine = entries.find((e) => e.profile.name === 'My Bound Test');
expect(mine?.profile.layoutId).toBe('horde');
// And the auto-selected newest entry already picks the bound layout.
await expect(page.getByTestId('layout-picker')).toHaveValue('horde');
});
});