diff --git a/lib/exporter/image.responsive.options.matrix.test.ts b/lib/exporter/image.responsive.options.matrix.test.ts
new file mode 100644
index 0000000..33563c1
--- /dev/null
+++ b/lib/exporter/image.responsive.options.matrix.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect } from 'vitest'
+import { exportPage } from '@/lib/exporter/html'
+import type { ExportOptions } from '@/lib/exporter/html'
+import type { Page } from '@/lib/schema/page'
+
+const basePage: Page = {
+ title: 'Img Matrix',
+ locale: 'en',
+ theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
+ sections: [
+ { type: 'hero', props: { heading: 'Hero', subheading: '', imageUrl: 'https://example.com/hero.jpg' } },
+ ],
+ form: {
+ actionUrl: '',
+ fields: [],
+ spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
+ },
+ analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
+ seo: { title: 'T', description: 'D' },
+}
+
+describe('image responsive options - matrix', () => {
+ it('applies loading and decoding attributes from options', () => {
+ const opts: ExportOptions = { imageLoading: 'eager', imageDecoding: 'sync' }
+ const { html } = exportPage(basePage, opts)
+ expect(html).toMatch(/
]*loading="eager"/)
+ expect(html).toMatch(/
]*decoding="sync"/)
+ })
+
+ it('sets fetchpriority="low" when specified', () => {
+ const opts: ExportOptions = { imageFetchPriority: 'low' }
+ const { html } = exportPage(basePage, opts)
+ expect(html).toMatch(/
]*fetchpriority="low"/)
+ })
+
+ it('prefers preset sizes over auto default when both provided (no explicit sizes)', () => {
+ const opts: ExportOptions = {
+ imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
+ imageAutoSizes: true,
+ imageSizesPreset: 'mobile-first',
+ }
+ const { html } = exportPage(basePage, opts)
+ // preset should win over default auto sizes
+ expect(html).toMatch(/
]*sizes="\(max-width: 640px\) 100vw, 800px"/)
+ })
+})
diff --git a/tests/e2e/wysiwyg-group-resize-rotate.e2e.ts b/tests/e2e/wysiwyg-group-resize-rotate.e2e.ts
new file mode 100644
index 0000000..4fb87fe
--- /dev/null
+++ b/tests/e2e/wysiwyg-group-resize-rotate.e2e.ts
@@ -0,0 +1,89 @@
+import { test, expect } from '@playwright/test'
+
+const wysiwygUrl = '/en/wysiwyg'
+
+function snap8(v: number) { return Math.round(v / 8) * 8 }
+
+async function getBoxStyles(page: import('@playwright/test').Page, nth: number) {
+ const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
+ const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
+ const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
+ const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
+ const height = await el.evaluate((n) => parseInt((n as HTMLElement).style.height || '0', 10))
+ const transform = await el.evaluate((n) => (n as HTMLElement).style.transform || '')
+ return { left, top, width, height, transform }
+}
+
+test.describe('WYSIWYG - Group resize/rotate and guide live region', () => {
+ test('group E handle resizes both by snapped delta without shifting x', async ({ page }) => {
+ await page.goto(wysiwygUrl)
+ const canvas = page.getByTestId('wysiwyg-canvas')
+ await expect(canvas).toBeVisible()
+
+ const addText = page.getByRole('button', { name: /^Add Text$/i })
+ await addText.click()
+ await addText.click()
+
+ // Marquee select both
+ const box = await canvas.boundingBox()
+ if (!box) throw new Error('canvas not found')
+ await page.mouse.move(box.x + 40, box.y + 40)
+ await page.mouse.down()
+ await page.mouse.move(box.x + 260, box.y + 160)
+ await page.mouse.up()
+
+ const live = page.getByTestId('aria-live')
+ await expect(live).toContainText(/선택\s*2개/)
+
+ const b0 = await getBoxStyles(page, 0)
+ const b1 = await getBoxStyles(page, 1)
+
+ // Drag east handle of second by +16px
+ const target = page.locator('[data-testid^="obj-text-"]').nth(1)
+ const tbox = await target.boundingBox()
+ if (!tbox) throw new Error('target not found')
+ await page.mouse.move(tbox.x + tbox.width - 5, tbox.y + 5)
+ await page.mouse.down()
+ await page.mouse.move(tbox.x + tbox.width + 16, tbox.y + 5)
+ await page.mouse.up()
+
+ const a0 = await getBoxStyles(page, 0)
+ const a1 = await getBoxStyles(page, 1)
+
+ const dw = snap8(b1.width + 16) - b1.width
+ expect(a0.left).toBe(b0.left)
+ expect(a1.left).toBe(b1.left)
+ expect(a0.width).toBe(b0.width + dw)
+ expect(a1.width).toBe(b1.width + dw)
+ })
+
+ test('rotate handle snaps to 15deg and live region includes guide coords while dragging', async ({ page }) => {
+ await page.goto(wysiwygUrl)
+ const canvas = page.getByTestId('wysiwyg-canvas')
+ await expect(canvas).toBeVisible()
+
+ const addText = page.getByRole('button', { name: /^Add Text$/i })
+ await addText.click()
+
+ const target = page.locator('[data-testid^="obj-text-"]').first()
+ const tbox = await target.boundingBox()
+ if (!tbox) throw new Error('target not found')
+
+ // Select
+ await target.click()
+
+ // Drag rotate handle horizontally to cause rotation ~15deg (snapped)
+ const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
+ const hbox = await rotateHandle.boundingBox()
+ if (!hbox) throw new Error('rotate handle not found')
+ await page.mouse.move(hbox.x + 5, hbox.y + 5)
+ await page.mouse.down()
+ await page.mouse.move(hbox.x + 40, hbox.y + 5)
+ const live = page.getByTestId('aria-live')
+ await expect(live).toContainText(/가이드\s*x=|y=/)
+ await page.mouse.up()
+
+ const a = await getBoxStyles(page, 0)
+ expect(a.transform).toMatch(/rotate\(15deg\)|rotate\(30deg\)/)
+ })
+})