diff --git a/packages/sw/tests/fixtures/tool-group.tsx b/packages/sw/tests/fixtures/tool-group.tsx
new file mode 100644
index 0000000..c4b685b
--- /dev/null
+++ b/packages/sw/tests/fixtures/tool-group.tsx
@@ -0,0 +1,21 @@
+// Test-only host for keyboard-dismiss.browser.mjs (2026-10-20): SWToolGroup selection="single" has no current Story
+// (SW/Tool was removed in 7f42923). Public @hm-design/sw API only; the host owns the pressed option.
+import { useState } from "react"
+import { createRoot } from "react-dom/client"
+import { SWTheme, SWToolButton, SWToolGroup } from "@hm-design/sw"
+import "@hm-design/sw/styles.css"
+
+const OPTIONS = [{ id: "a", label: "모델" }, { id: "b", label: "배치" }, { id: "c", label: "단면", disabled: true }, { id: "d", label: "상세" }]
+
+function Host() {
+ const [selected, setSelected] = useState("b")
+ return (
+
+
+ {OPTIONS.map(option => setSelected(option.id)}>{option.label})}
+
+
+ )
+}
+
+createRoot(document.getElementById("root")!).render()
diff --git a/packages/sw/tests/keyboard-dismiss.browser.mjs b/packages/sw/tests/keyboard-dismiss.browser.mjs
new file mode 100644
index 0000000..f35a21a
--- /dev/null
+++ b/packages/sw/tests/keyboard-dismiss.browser.mjs
@@ -0,0 +1,117 @@
+// Roving focus and outside-pointer dismissal of the shared internal helpers (2026-10-20): moveRovingFocus
+// (SWToolGroup, SWFooter, SWViewerHeader tabs, SWHomeMenu) and useOutsidePointerDown (SWHeader dropdowns, layer menu,
+// document title, More). The existing suites only moved between middle items and closed menus by moving focus, which
+// also closes them; this suite checks wrap-around, Home / End, disabled skipping, selection, and a pointerdown outside
+// that leaves focus in place. SWToolGroup runs on a test-only fixture; the others on the built Storybook.
+// Build Storybook first. Usage: node packages/sw/tests/keyboard-dismiss.browser.mjs
+// SW_STORYBOOK_DIR: another built Storybook (e.g. a copy built before a change) instead of storybook-static.
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+import http from 'node:http'
+import { fileURLToPath } from 'node:url'
+import { loadPlaywright } from './playwright-runtime.mjs'
+
+if (!process.argv[2]) throw new Error('Pass a new evidence directory')
+const out = path.resolve(process.argv[2]), site = path.join(out, 'fixture')
+await mkdir(site, { recursive: true })
+await createRequire(import.meta.url)('esbuild').build({
+ entryPoints: [fileURLToPath(new URL('./fixtures/tool-group.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true,
+ loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning',
+})
+await writeFile(path.join(site, 'index.html'), '')
+
+const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
+const serve = root => { const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } }); return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server))) }
+const [fixtureServer, storybookServer] = await Promise.all([serve(site), serve(path.resolve(process.env.SW_STORYBOOK_DIR ?? 'storybook-static'))])
+const fixtureBase = `http://127.0.0.1:${fixtureServer.address().port}`, base = `http://127.0.0.1:${storybookServer.address().port}`
+const report = { checks: [], errors: [] }
+const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) }
+const { chromium } = loadPlaywright()
+const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
+report.browser = browser.version()
+const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
+page.on('pageerror', e => report.errors.push(e.message))
+page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
+const story = async id => {
+ await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`)
+ await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor(); await page.evaluate(() => document.fonts.ready); await page.mouse.move(1, 1)
+}
+// Marks the focused element so later keys can be compared with it; returns its descriptor.
+const mark = name => name.replace(/\s+/g, '_')
+const focused = name => page.evaluate(name => { const e = document.activeElement; if (!e || e === document.body) return null; e.dataset.kbMark = (e.dataset.kbMark ? e.dataset.kbMark + ' ' : '') + name; return (e.getAttribute('aria-label') ?? e.textContent).trim().slice(0, 24) }, mark(name))
+const isMarked = name => page.evaluate(name => (document.activeElement?.dataset.kbMark ?? '').split(' ').includes(name), mark(name))
+// Ends found with Home / End, then: End + forward wraps to the first, Home + backward wraps to the last.
+async function roving(tag, first, { forward = 'ArrowRight', backward = 'ArrowLeft', ignored = [] } = {}) {
+ await first.focus()
+ await page.keyboard.press('Home'); const head = await focused(`${tag}-head`)
+ await page.keyboard.press('End'); const tail = await focused(`${tag}-tail`)
+ check(`${tag}: Home and End reach two different ends`, !!head && !!tail && head !== tail, { head, tail })
+ await page.keyboard.press(forward)
+ check(`${tag}: ${forward} on the last item wraps to the first`, await isMarked(`${tag}-head`), { head, tail, now: await focused('x') })
+ await page.keyboard.press(backward)
+ check(`${tag}: ${backward} on the first item wraps to the last`, await isMarked(`${tag}-tail`), { head, tail, now: await focused('x') })
+ for (const key of ignored) { await page.keyboard.press(key); check(`${tag}: ${key} does not move focus`, await isMarked(`${tag}-tail`), await focused('x')) }
+}
+
+try {
+ // 1. SWToolGroup selection="single" (radio): arrows in both axes, disabled option skipped, focus selects.
+ await page.goto(fixtureBase); await page.locator('[data-tool-fixture] [role="radiogroup"]').waitFor()
+ const option = id => page.locator(`[data-tool-fixture] [data-option="${id}"]`)
+ const selected = () => page.locator('[data-tool-fixture]').getAttribute('data-selected')
+ const at = async () => page.evaluate(() => document.activeElement?.dataset.option)
+ await option('b').focus()
+ const steps = []
+ for (const key of ['ArrowRight', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End']) { await page.keyboard.press(key); steps.push([key, await at(), await selected()]) }
+ const expected = [['ArrowRight', 'd', 'd'], ['ArrowRight', 'a', 'a'], ['ArrowLeft', 'd', 'd'], ['ArrowDown', 'a', 'a'], ['ArrowUp', 'd', 'd'], ['Home', 'a', 'a'], ['End', 'd', 'd']]
+ check('SWToolGroup radio: skips the disabled option, wraps both ways, ArrowDown / ArrowUp move, Home / End, focus selects', JSON.stringify(steps) === JSON.stringify(expected), { steps, expected })
+ check('SWToolGroup radio: aria-checked follows the selection', await option('d').getAttribute('aria-checked') === 'true' && await option('a').getAttribute('aria-checked') === 'false')
+
+ // 2. SWFooter tool row (horizontal only).
+ await story('sw-footer--full-footer')
+ await roving('SWFooter tools', page.locator('.hm-sw-footer__slots[role="toolbar"] .hm-sw-footer-tool').first(), { ignored: ['ArrowDown', 'ArrowUp'] })
+
+ // 3. SWViewerHeader tabs (radio, both axes, focus selects).
+ await story('sw-viewer--viewer-header')
+ const tabs = page.locator('.hm-sw-viewer-header__tabs[role="radiogroup"]').first()
+ await roving('SWViewerHeader tabs', tabs.locator('[role="radio"]:not(:disabled)').first(), { forward: 'ArrowDown', backward: 'ArrowUp' })
+ check('SWViewerHeader tabs: the focused tab is the checked one', await page.evaluate(() => document.activeElement?.getAttribute('aria-checked')) === 'true')
+
+ // 4. SWHomeMenu tiles (horizontal only).
+ await story('sw-header--home-menu-standalone')
+ await roving('SWHomeMenu tiles', page.locator('.hm-sw-home-menu__item').first(), { ignored: [] })
+
+ // 5. Outside pointerdown that leaves focus on the trigger: closes the open menu; inside it does not.
+ const pointerdown = selector => page.evaluate(selector => document.querySelector(selector).dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })), selector)
+ await story('sw-header--composed-controls')
+ const host = page.locator('[data-composed-controls]'), menu = () => page.locator('[data-composed-controls] .hm-sw-header__menu-panel, [data-composed-controls] .hm-sw-header__layer-menu')
+ for (const label of ['레이어', '색상', '선스타일', '문자스타일', '치수스타일']) {
+ const trigger = host.getByRole('button', { name: label, exact: true })
+ await trigger.click()
+ await pointerdown('[data-composed-controls] :is(.hm-sw-header__menu-panel, .hm-sw-header__layer-menu)')
+ const keptInside = await menu().count() === 1
+ await pointerdown('body')
+ check(`${label}: pointerdown inside keeps the menu; outside closes it with focus kept on the trigger`, keptInside && await menu().count() === 0 && await trigger.evaluate(e => e === document.activeElement))
+ }
+ await story('sw-header--document-dropdown')
+ const documentButton = page.locator('[data-callback-log] + * .hm-sw-header__document-button').first()
+ await documentButton.click()
+ const documentMenu = () => page.locator('[data-callback-log] + * .hm-sw-header__document-menu, [data-callback-log] + * .hm-sw-header__document-list')
+ const documentOpen = await documentMenu().count() > 0 && await documentButton.getAttribute('aria-expanded') === 'true'
+ await pointerdown('body')
+ check('Document title: outside pointerdown closes the list, focus kept', documentOpen && await documentButton.getAttribute('aria-expanded') === 'false' && await documentButton.evaluate(e => e === document.activeElement), { documentOpen })
+ await story('sw-header--responsive-more')
+ const more = page.locator('.hm-sw-header__more-trigger:visible').first()
+ await more.click()
+ const moreOpen = await more.getAttribute('aria-expanded') === 'true'
+ await pointerdown('body')
+ check('More: outside pointerdown closes it, focus kept', moreOpen && await more.getAttribute('aria-expanded') === 'false' && await more.evaluate(e => e === document.activeElement), { moreOpen })
+ check('no browser console or page errors', report.errors.length === 0, report.errors)
+} catch (error) { check('suite completed', false, error.stack) }
+finally {
+ await browser.close(); fixtureServer.close(); storybookServer.close()
+ report.status = report.checks.every(c => c.status === 'PASS') ? 'PASS' : 'FAIL'
+ await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2) + '\n')
+ console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
+ process.exitCode = report.status === 'PASS' ? 0 : 1
+}