refactor(storybook): remove unused SW header demo stories

Remove nine SW/Header demo pages (property-fields, property-shape-slot,
composition, colorize, dynamic-controls, property-bar, additional-hover,
property-panel, layer-detail-input) and the demo code and Storybook-only
CSS used only by them. SW Stories 19 -> 10, total 338 -> 329.

The package runtime, public API, AI template and the retained Stories
(Overview, Dropdown, Property Panel 4-state comparison, Window Move
Motion and others) are unchanged. Add a current regression script and
mark the older scripts that open the retired IDs as historical evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 13:10:34 +09:00
1 parent c8e5fbeebc
commit 5faa1f1ffe
58 files changed
+10577 -499

No files matched your search

@@ -1,3 +1,5 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (composition, colorize, layer-detail-input; preservation/verify modes). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
import { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
@@ -1,3 +1,5 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-bar, additional-hover). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Usage: node packages/sw/tests/followup-four.browser.mjs <evidence-dir> [storybook-url] (Storybook uses packages/sw/dist: build first)
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
@@ -1,3 +1,5 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-panel). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Property panel close button (Figma 26:12366): canvas bleed, Default / Hover, close by click and Escape.
// Usage: node packages/sw/tests/property-close.browser.mjs <evidence-dir> <label> [storybook-url]
// Storybook serves packages/sw/dist: build @hm-design/sw first.
@@ -1,3 +1,5 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-fields). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Property fields (Figma 26:12388 ~ 26:12649): Default / real pointer Hover / Mouse Leave / On (editing, dropdown),
// selection callbacks, keyboard, Escape, external data, Disabled / ReadOnly, and the four panel states.
// Usage: node packages/sw/tests/property-fields.browser.mjs <evidence-dir> <label> [storybook-url]
@@ -1,3 +1,5 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-panel and four others in verify). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
import { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
@@ -1,3 +1,5 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (dynamic-controls, composition). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Scoped verification for the five approved Story removals; serves the production build locally.
// Usage: node packages/sw/tests/storybook-cleanup-stage1.browser.mjs [evidence-dir]
import { createRequire } from 'node:module'
@@ -3,6 +3,8 @@ export const retiredStage1Ids = ['responsive', 'text-stroke', 'dynamic-data', 'd
export const retiredDropdownIds = ['layer-dropdown', 'color-dropdown', 'line-style-dropdown', 'text-dimension-style-dropdown'].map(id => `sw-header--${id}`)
// 2026-10-19: duplicate property panel demos; the four states live on sw-header--property-panel-compare.
export const retiredPropertyPanelIds = ['property-panel-variants', 'property-panel-none', 'property-panel-all', 'property-panel-circle', 'property-panel-expanded'].map(id => `sw-header--${id}`)
// 2026-10-19: unused SW Header demo pages; their features stay in the package and the retained Stories.
export const retiredUnusedDemoIds = ['property-fields', 'property-shape-slot', 'composition', 'colorize', 'dynamic-controls', 'property-bar', 'additional-hover', 'property-panel', 'layer-detail-input'].map(id => `sw-header--${id}`)
// Include the old title when reading historical evidence; live hierarchy is asserted separately.
export const swStoryEntries = index => Object.values(index.entries).filter(entry => entry.type === 'story' && (entry.title === 'SW/Header' || entry.title === 'SW/Header/Dropdown'))
export const retainedInventory = entries => entries.filter(entry => ![...retiredStage1Ids, ...retiredDropdownIds].includes(entry.id))
@@ -0,0 +1,209 @@
// Current regression after removing nine unused SW Header demo pages (2026-10-19, 19 -> 10 SW Stories).
// Build Storybook first. Usage: node packages/sw/tests/storybook-unused-removal.browser.mjs [evidence-dir]
// Older scripts that open the retired IDs (detail-buttons preservation/verify, followup-four, property-close,
// property-fields, property-panel-comparison verify, storybook-cleanup-stage1) are historical evidence for their runs.
import { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { parse } from '@babel/parser'
import { swStoryEntries, retiredUnusedDemoIds } from './storybook-inventory.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-unused-story-removal-qa/verify')
const qa = 'docs/sw/storybook-unused-story-removal-qa/before'
const baseCommit = 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d'
await mkdir(out, { recursive: true })
const report = { baseCommit, checks: [], errors: [], data: {} }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) }
// ---- Inventory: exactly the nine requested IDs leave; every other entry keeps ID, name, title, tags and order.
const beforeIndex = JSON.parse(await readFile(path.join(qa, 'full-index-before.json'), 'utf8'))
const afterIndex = JSON.parse(await readFile('storybook-static/index.json', 'utf8'))
const stories = index => Object.values(index.entries).filter(e => e.type === 'story')
const sig = list => JSON.stringify(list.map(e => [e.id, e.name, e.title, e.tags]))
const beforeSW = swStoryEntries(beforeIndex), afterSW = swStoryEntries(afterIndex)
const expected = beforeSW.filter(e => !retiredUnusedDemoIds.includes(e.id))
report.data.inventory = { before: [stories(beforeIndex).length, beforeSW.length], after: [stories(afterIndex).length, afterSW.length], sw: afterSW.map(e => [e.id, e.name]) }
await writeFile(path.join(out, 'story-inventory-after.json'), JSON.stringify({ total: stories(afterIndex).length, sw: afterSW.map(e => ({ id: e.id, name: e.name, title: e.title, tags: e.tags })) }, null, 2) + '\n')
check('inventory: 19 -> 10 SW Stories, 338 -> 329 total', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329, report.data.inventory)
check('inventory: the nine requested IDs existed before and are absent now', retiredUnusedDemoIds.length === 9 && retiredUnusedDemoIds.every(id => beforeIndex.entries[id] && !afterIndex.entries[id]))
check('inventory: retained SW IDs, names, titles, tags and order unchanged', sig(expected) === sig(afterSW), afterSW.map(e => e.id))
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id))
check('inventory: every non-removed index entry identical (docs and other packages included)', JSON.stringify(others(beforeIndex)) === JSON.stringify(others(afterIndex)))
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
// ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched.
const sourcePath = 'src/sw/sw-header.stories.tsx'
const exports = text => Object.fromEntries(parse(text, { sourceType: 'module', plugins: ['typescript', 'jsx'] }).program.body
.filter(n => n.type === 'ExportNamedDeclaration' && n.declaration).map(n => [n.declaration.declarations?.[0].id.name ?? n.declaration.id.name, text.slice(n.start, n.end)]))
const oldExports = exports(execFileSync('git', ['show', `${baseCommit}:${sourcePath}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n'))
const newExports = exports((await readFile(sourcePath, 'utf8')).replaceAll('\r\n', '\n'))
const removedExports = ['PropertyFields', 'PropertyShapeSlot', 'Composition', 'Colorize', 'DynamicControls', 'PropertyBar', 'AdditionalHover', 'PropertyPanel', 'LayerDetailInput']
check('source: the nine Story exports are removed', removedExports.every(name => oldExports[name] && !newExports[name]))
const changed = Object.keys(oldExports).filter(name => !removedExports.includes(name) && oldExports[name] !== newExports[name])
check('source: retained exports (Stories and ComposedDemo) byte-identical', changed.length === 0 && Object.keys(newExports).every(name => oldExports[name]), changed)
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'packages/sw/README.md', 'packages/ui', 'packages/org-chart', 'packages/org-chart-react', 'packages/center-graphic', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-header-dropdown.stories.tsx', 'src/sw/sw-composed-controls.css', 'src/sw/assets', '.storybook']
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim()
check('source: runtime, public API, AI template, other packages, Overview, Dropdown CSF, assets and Storybook config untouched', diff === '', diff)
// ---- Browser
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.gif': 'image/gif', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
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() } })
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
report.browser = browser.version()
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const goto = async id => {
await page.goto(`${base}/iframe.html?id=sw-header--${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)
}
const noDocOverflow = () => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)
try {
// Sidebar: the manager lists the ten retained Stories in order, none of the nine menus.
await page.goto(`${base}/?path=/story/sw-header--overview`)
await page.frameLocator('#storybook-preview-iframe').locator('[data-preview-stage]').waitFor()
const sidebar = await page.locator('[data-item-id^="sw-header--"]').evaluateAll(list => [...new Map(list.map(e => [e.dataset.itemId, e.textContent.replace('Skip to content', '').trim()])).entries()])
report.data.sidebar = sidebar
await writeFile(path.join(out, 'manager-story-order.json'), JSON.stringify(sidebar, null, 2) + '\n')
// .storybook/main.ts excludes `verification` Stories (Detail Buttons) from the default sidebar; direct URLs stay.
const visible = afterSW.filter(e => !e.tags.includes('verification'))
check('sidebar: retained non-verification Stories in index order with their names', JSON.stringify(sidebar) === JSON.stringify(visible.map(e => [e.id, e.name])), sidebar)
const removedNames = beforeSW.filter(e => retiredUnusedDemoIds.includes(e.id)).map(e => e.name)
check('sidebar: none of the nine removed menu IDs or names remain', sidebar.every(([id, text]) => !retiredUnusedDemoIds.includes(id) && !removedNames.includes(text)), removedNames)
await page.screenshot({ path: path.join(out, 'manager-sidebar.png') })
// A removed URL no longer resolves to a Story.
await page.goto(`${base}/iframe.html?id=sw-header--colorize&viewMode=story`)
await page.waitForTimeout(1500)
check('removed URL renders no SW Story', await page.locator('.hm-sw-header, .hm-sw-theme').count() === 0)
report.errors.length = 0 // the missing-story error above is expected; start the regression clean
for (const entry of afterSW) {
await goto(entry.id.replace('sw-header--', ''))
check(`render ${entry.id}`, !(await page.locator('.sb-errordisplay').isVisible()))
}
// Overview: Header 49px, Stage, Dropdown open/close, Bar edit, panel open/Escape, callbacks.
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 }); await goto('overview')
const header = page.locator('header.hm-sw-header')
const height = await header.evaluate(h => h.getBoundingClientRect().height)
check(`Overview ${width}: one Header 49px, one Stage, no page overflow`, height === 49 && await header.count() === 1 && await page.locator('[data-preview-stage]').count() === 1 && await noDocOverflow(), { height })
await page.screenshot({ path: path.join(out, `overview-${width}.png`) })
}
await page.setViewportSize({ width: 1440, height: 900 }); await goto('overview')
{
const header = page.locator('header.hm-sw-header'), callback = () => page.locator('[data-preview-callback]').textContent()
const layer = header.getByRole('button', { name: '레이어', exact: true })
await layer.click()
check('Overview: layer Dropdown opens', await header.locator('.hm-sw-header__layer-panel').count() === 1 && await layer.getAttribute('aria-expanded') === 'true')
await page.keyboard.press('Escape')
check('Overview: Escape closes Dropdown and restores focus', await header.locator('.hm-sw-header__layer-panel').count() === 0 && await layer.evaluate(e => e === document.activeElement))
await layer.click(); await header.getByRole('button', { name: '레이어 상세', exact: true }).click()
check('Overview: layer Detail callback (former Layer Detail page) and close', (await callback()).includes('onLayerDetail') && await header.locator('.hm-sw-header__layer-panel').count() === 0)
await header.getByRole('button', { name: '색상', exact: true }).click()
const pick = header.getByRole('button', { name: '색상 추출', exact: true })
const pickBox = await pick.boundingBox()
await pick.click()
check('Overview: btn_colorize 24 × 24 calls onColorPick and closes (former Colorize page)', pickBox.width === 24 && pickBox.height === 24 && (await callback()).includes('onColorPick') && await header.locator('.hm-sw-header__color-menu').count() === 0, pickBox)
const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] input')
await bar.fill('6.25'); await bar.press('Enter')
check('Overview: property Bar Enter commits through callback', await bar.inputValue() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)'))
await bar.fill('-1'); await bar.press('Enter')
check('Overview: property Bar validation keeps min=0', await bar.inputValue() === '6.25')
const trigger = header.locator('[data-property-panel-trigger]')
await trigger.click(); await page.locator('.hm-sw-property').waitFor()
check('Overview: panel opens with the shared Bar value', await page.locator('.hm-sw-property [data-property-id="radius"] input').first().inputValue() === '6.25')
await page.keyboard.press('Escape'); await page.keyboard.press('Escape')
check('Overview: Escape closes panel and returns focus to its trigger', await page.locator('.hm-sw-property').count() === 0 && await trigger.evaluate(e => e === document.activeElement))
}
// Dropdown: six controls, real open, selection, Escape and focus return.
await goto('composed-controls')
{
const host = page.locator('[data-composed-controls]'), field = name => host.getByRole('button', { name, exact: true })
const menu = () => page.locator('[data-composed-controls] .hm-sw-header__menu-panel, [data-composed-controls] .hm-sw-header__layer-menu')
check('Dropdown: five fields plus the 상세보기 group (6 controls)', await host.locator('.hm-sw-header__field').count() === 5 && await host.getByRole('group', { name: '상세보기', exact: true }).count() === 1)
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 })
for (const label of ['레이어', '색상', '선스타일', '문자스타일', '치수스타일']) {
await field(label).click()
const opened = await menu().count() === 1 && await field(label).getAttribute('aria-expanded') === 'true'
await page.keyboard.press('Escape')
check(`Dropdown ${width}: ${label} opens, Escape closes, focus returns`, opened && await menu().count() === 0 && await field(label).evaluate(e => e === document.activeElement))
}
check(`Dropdown ${width}: no page overflow`, await noDocOverflow())
await page.screenshot({ path: path.join(out, `dropdown-${width}.png`), fullPage: true })
}
await page.setViewportSize({ width: 1440, height: 900 })
await field('문자스타일').click(); await page.locator('.hm-sw-header__list-menu--text-style').getByRole('button', { name: /Dotum/ }).click()
check('Dropdown: text style selection reflected in field and status', (await field('문자스타일').textContent()).includes('Dotum') && (await page.locator('[data-style-state]').textContent()).includes('Dotum'))
await field('선스타일').click()
const center = page.getByRole('button', { name: 'CENTER', exact: true })
await center.click()
check('Dropdown: line style selection reflected', (await page.locator('[data-style-state]').textContent()).includes('CENTER'))
if (await menu().count()) await page.keyboard.press('Escape')
}
// Comparison: 2 × 2, original sizes, Inner scroll reaches the 754px bottom, input and dropdown work.
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 }); await goto('property-panel-compare')
const g = await page.evaluate(() => {
const r = e => e.getBoundingClientRect(), s = document.querySelector('[data-compare-scroll]')
const cells = [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, x: r(c).x, y: r(c).y, right: r(c).right, bottom: r(c).bottom, w: r(c.querySelector('.hm-sw-property')).width, h: r(c.querySelector('.hm-sw-property')).height }))
s.scrollTop = s.scrollHeight; s.scrollLeft = s.scrollWidth
const last = r(document.querySelectorAll('[data-compare] .hm-sw-property')[3]), sr = r(s)
return { cells, reach: last.bottom <= sr.bottom + 0.5 && last.right <= sr.right + 0.5, docFits: document.documentElement.scrollHeight <= innerHeight && document.documentElement.scrollWidth <= innerWidth }
})
const [a, b, c, d] = g.cells
report.data[`compare${width}`] = g
check(`Compare ${width}: four states in order at 314 × 256 / 361 / 658 / 754`, JSON.stringify(g.cells.map(e => [e.id, e.w, e.h])) === JSON.stringify([['none', 314, 256], ['all', 314, 361], ['circle', 314, 658], ['expanded', 314, 754]]), g.cells)
check(`Compare ${width}: 2 columns × 2 rows`, Math.abs(a.y - b.y) < 0.5 && Math.abs(c.y - d.y) < 0.5 && Math.abs(a.x - c.x) < 0.5 && Math.abs(b.x - d.x) < 0.5 && b.x >= a.right && c.y >= Math.max(a.bottom, b.bottom))
check(`Compare ${width}: Inner scroll reaches the 754px panel bottom; page does not scroll`, g.reach && g.docFits, g)
await page.screenshot({ path: path.join(out, `compare-${width}-end.png`) })
}
await page.setViewportSize({ width: 1440, height: 900 }); await goto('property-panel-compare')
{
const cell = id => page.locator(`[data-compare="${id}"]`), log = () => page.locator('[data-property-log]').textContent()
const radius = cell('circle').locator('[data-property-id="radius"] input.hm-sw-header__input').first()
await radius.scrollIntoViewIfNeeded(); await radius.click()
check('Compare: editable Input caret #E60403 (former Layer Detail · Input page)', await radius.evaluate(e => getComputedStyle(e).caretColor === 'rgb(230, 4, 3)'))
await radius.fill('5.5'); await radius.press('Enter')
check('Compare: Input Enter commits only in its own panel', (await radius.inputValue()).startsWith('5.5') && (await log()).includes('circle: property(radius, 5.5'))
await radius.click(); await radius.fill('9'); await radius.press('Escape')
check('Compare: Input Escape cancels', (await radius.inputValue()).startsWith('5.5'))
const trigger = cell('all').locator('[data-property-id="layer"] button[aria-haspopup], [data-property-id="layer"] [aria-expanded]').first()
await trigger.scrollIntoViewIfNeeded(); await trigger.click()
const drop = page.locator('.hm-sw-property__drop').first(); await drop.waitFor({ timeout: 2000 })
await drop.getByRole('button', { name: 'CC-D GNL', exact: true }).first().click()
check('Compare: layer Dropdown selection commits via callback', (await log()).includes('all: property(layer, CC-D GNL)'))
if (await page.locator('.hm-sw-property__drop').count()) await page.keyboard.press('Escape')
await trigger.click(); await page.locator('.hm-sw-property__drop').first().waitFor()
await page.keyboard.press('Escape')
check('Compare: Escape closes the Dropdown and returns focus', await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement)))
}
// Window Move Motion: the single 3x step section with nine paused stages, live Hover 1400ms.
await goto('window-move-motion')
{
const stages = await page.locator('[data-motion-time]').evaluateAll(list => list.map(e => ({ time: e.dataset.motionTime, animations: e.querySelector('.hm-sw-header__motion')?.getAnimations({ subtree: true }).length ?? -1, zoom: getComputedStyle(e.querySelector('.sw-story-zoom')).zoom })))
check('Motion: one section 「단계별 진행 (3배 확대)」 with nine stages at zoom 3', await page.getByRole('heading', { level: 2 }).allTextContents().then(t => JSON.stringify(t) === JSON.stringify(['단계별 진행 (3배 확대)'])) && stages.length === 9 && stages.every(s => Number(s.zoom) === 3), stages)
check('Motion: rest stage static, timed stages hold three paused animations', stages[0].time === 'rest' && stages[0].animations === 0 && stages.slice(1).every(s => s.animations === 3), stages)
}
check('no browser console or page errors', report.errors.length === 0, report.errors)
} catch (e) { check('suite completed', false, e.stack) }
finally {
report.status = report.checks.every(c => c.status === 'PASS') ? 'PASS' : 'FAIL'
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2) + '\n')
await browser.close(); server.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length, report.status)
process.exitCode = report.status === 'PASS' ? 0 : 1
}