feat(sw): viewer follow-up — SVG icon outline, shared SW popup frame, story grid, green layer buttons
- A: SWViewerIcon draws the SWHeader black outline as SVG stroke on a copy behind the icon (no CSS filter) - B: SWViewerPanel renders the common SWPopup frame (same DOM / CSS) and adds drag, corner resize, collapse, bounds and modal; SWPopup gains optional titleActions / footer; title art spans any width - C: shared Story canvas grid (--sw-story-canvas) behind the SW Header work area and dropdown room - D: SWViewerButton tone="green" for 전체 켜기 / 전체 끄기: default kept, hover = primary (확인) hover token, pressed = primary face - AI guide, verification scripts (76 checks, element regression) and evidence updated
This commit is contained in:
1 parent
7f42923ccf
commit
68fbcf6e15
45 files changed
+1463
-377
No files matched your search
@@ -1,5 +1,7 @@
|
||||
// Regression for the SW viewer work (2026-10-19): the existing SW/Header Stories must render pixel-identical
|
||||
// before (base Storybook) and after (this branch) at several widths.
|
||||
// Regression for the SW viewer work (2026-10-19): the existing SW/Header and SW/Popup Stories must render their
|
||||
// headers and popups pixel-identical before (base Storybook) and after (this branch) at several widths.
|
||||
// Element mode compares every .hm-sw-header / .hm-sw-property element; the full page is reported for information
|
||||
// (the 2026-10-19 follow-up changed the Story work-area background to the shared grid canvas on purpose).
|
||||
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
@@ -10,38 +12,51 @@ const require = createRequire(path.join(runtime, 'index.js'))
|
||||
const { chromium } = require('playwright')
|
||||
const { PNG } = require('pngjs')
|
||||
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
|
||||
const [base, next, outArg] = process.argv.slice(2)
|
||||
const [base, next, outArg, mode] = process.argv.slice(2)
|
||||
// --neutral-canvas: on the new side only, put back the pre-grid Story backgrounds (white work area, page color under
|
||||
// dropdowns), so semi-transparent header / dropdown edge pixels compare against the same backdrop.
|
||||
const neutral = mode === "--neutral-canvas"
|
||||
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
|
||||
await mkdir(out, { recursive: true })
|
||||
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons']
|
||||
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons'].map(id => `sw-header--${id}`)
|
||||
.concat(['sw-popup--custom-content', 'sw-popup--multiple-popups'])
|
||||
const widths = [1920, 1180, 768]
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const shot = async (url, width) => {
|
||||
const shot = async (url, width, restore = false) => {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
|
||||
await page.goto(url)
|
||||
await page.waitForSelector('#storybook-root > *')
|
||||
if (restore) await page.addStyleTag({ content: '.sw-overview-stage { background: var(--hm-sem-bg-surface) !important; } .sw-story-scroller--menu { background: none !important; }' })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(400)
|
||||
const buffer = await page.screenshot({ fullPage: true, animations: 'disabled' })
|
||||
const full = PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
|
||||
const elements = []
|
||||
for (const element of await page.locator('.hm-sw-header, .hm-sw-property').all()) {
|
||||
if (!(await element.isVisible())) continue
|
||||
elements.push(PNG.sync.read(await element.screenshot({ animations: 'disabled' })))
|
||||
}
|
||||
await page.close()
|
||||
return PNG.sync.read(buffer)
|
||||
return { full, elements }
|
||||
}
|
||||
const compare = (a, b, out) => {
|
||||
if (a.width !== b.width || a.height !== b.height) return -1
|
||||
const image = new PNG({ width: a.width, height: a.height })
|
||||
const diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
|
||||
if (diff && out) return { diff, image }
|
||||
return diff
|
||||
}
|
||||
const results = []
|
||||
for (const id of ids) for (const width of widths) {
|
||||
const url = root => `${root}/iframe.html?id=sw-header--${id}&viewMode=story`
|
||||
const [a, b] = [await shot(url(base), width), await shot(url(next), width)]
|
||||
const same = a.width === b.width && a.height === b.height
|
||||
let diff = same ? 0 : -1
|
||||
if (same) {
|
||||
const image = new PNG({ width: a.width, height: a.height })
|
||||
diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
|
||||
if (diff) await writeFile(path.join(out, `${id}-${width}-diff.png`), PNG.sync.write(image))
|
||||
}
|
||||
results.push({ id, width, size: `${a.width}x${a.height} / ${b.width}x${b.height}`, diffPixels: diff, result: diff === 0 ? 'PASS' : 'FAIL' })
|
||||
console.log(`${diff === 0 ? 'PASS' : 'FAIL'} sw-header--${id} @${width} diff ${diff}`)
|
||||
const url = root => `${root}/iframe.html?id=${id}&viewMode=story`
|
||||
const [a, b] = [await shot(url(base), width), await shot(url(next), width, neutral)]
|
||||
const elementDiffs = a.elements.length === b.elements.length ? a.elements.map((item, index) => compare(item, b.elements[index])) : [-1]
|
||||
const fullDiff = compare(a.full, b.full)
|
||||
const pass = elementDiffs.every(diff => diff === 0)
|
||||
results.push({ id, width, elements: a.elements.length + ' / ' + b.elements.length, elementDiffPixels: elementDiffs, fullPageDiffPixels: fullDiff, result: pass ? 'PASS' : 'FAIL' })
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} @${width} elements ${a.elements.length}/${b.elements.length} diff [${elementDiffs.join(',')}] full-page diff ${fullDiff}`)
|
||||
}
|
||||
await browser.close()
|
||||
await writeFile(path.join(out, 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
|
||||
await writeFile(path.join(out, neutral ? 'regression-neutral-canvas.json' : 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
|
||||
const failed = results.filter(item => item.result !== 'PASS').length
|
||||
console.log(`${results.length - failed}/${results.length} PASS`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -157,7 +157,7 @@ if (comparison.reference) {
|
||||
check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`)
|
||||
// Drag: title bar moves the panel and it stays inside the frame.
|
||||
const before = await box(layerPanel)
|
||||
const title = layerPanel.locator('.hm-sw-viewer-panel__titlebar')
|
||||
const title = layerPanel.locator('.hm-sw-property__titlebar')
|
||||
const titleBox = await box(title)
|
||||
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
|
||||
await page.mouse.down()
|
||||
@@ -175,16 +175,29 @@ if (comparison.reference) {
|
||||
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
|
||||
// Collapse by button and double click.
|
||||
await layerPanel.getByRole('button', { name: '접기' }).click()
|
||||
check('collapse hides body', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 0)
|
||||
check('collapse hides body', !(await layerPanel.locator('.hm-sw-property__body').isVisible()))
|
||||
await title.dblclick({ position: { x: 30, y: 15 } })
|
||||
check('title double click expands', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 1)
|
||||
check('title double click expands', await layerPanel.locator('.hm-sw-property__body').isVisible())
|
||||
// Panel content works (layer eye).
|
||||
// 전체 켜기 / 전체 끄기: green on hover and while pressed, default otherwise; other panel buttons unchanged.
|
||||
const allOn = layerPanel.getByRole('button', { name: '전체 켜기' })
|
||||
const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' '))
|
||||
const restFace = await face(allOn)
|
||||
await allOn.hover()
|
||||
const hoverFace = await face(allOn)
|
||||
await page.mouse.down()
|
||||
const activeFace = await face(allOn)
|
||||
await page.mouse.up()
|
||||
const afterFace = await face(allOn)
|
||||
await page.mouse.move(5, 5)
|
||||
const leftFace = await face(allOn)
|
||||
check('전체 켜기: default kept, green hover / active, back after release', restFace === 'rgb(255, 255, 255) rgb(18, 51, 40)' && hoverFace === 'rgb(38, 102, 81) rgb(255, 255, 255)' && activeFace === 'rgb(18, 51, 40) rgb(255, 255, 255)' && afterFace === hoverFace && leftFace === restFace, [restFace, hoverFace, activeFace, afterFace, leftFace].join(' | '))
|
||||
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
|
||||
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
|
||||
// Close button hover face.
|
||||
const close = layerPanel.getByRole('button', { name: '닫기' })
|
||||
await close.hover()
|
||||
check('close hover #E40000 / white X', (await close.evaluate(element => [getComputedStyle(element).getPropertyValue('--hm-sw-viewer-close-glow').trim(), getComputedStyle(element).color].join(' '))) === '#e40000 rgb(255, 255, 255)')
|
||||
check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden'))
|
||||
await close.click()
|
||||
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
|
||||
// Two panels: the last touched comes to the front.
|
||||
@@ -192,12 +205,17 @@ if (comparison.reference) {
|
||||
await page.getByRole('button', { name: '설정', exact: true }).click()
|
||||
const options = page.getByRole('dialog', { name: '옵션' })
|
||||
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
|
||||
await layerPanel.locator('.hm-sw-viewer-panel__titlebar').click({ position: { x: 30, y: 15 } })
|
||||
await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } })
|
||||
const [zl, zo] = await z()
|
||||
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
|
||||
// Modal popup: backdrop, focus trap, Escape.
|
||||
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
|
||||
const about = page.getByRole('dialog', { name: /라이선스/ })
|
||||
const confirm = about.getByRole('button', { name: '확인' })
|
||||
await confirm.hover()
|
||||
const confirmHover = await face(confirm)
|
||||
check('전체 켜기 hover = 라이선스 확인 hover (same token)', confirmHover === hoverFace, `${confirmHover} / ${hoverFace}`)
|
||||
await about.locator('.hm-sw-property__title').hover()
|
||||
check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1)
|
||||
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
|
||||
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
|
||||
@@ -343,7 +361,13 @@ if (comparison.reference) {
|
||||
return {
|
||||
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
|
||||
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
|
||||
count: style('.hm-sw-viewer-header__count').textShadow, icon: style('.hm-sw-viewer-header__icon').filter, chevron: style('.hm-sw-viewer-header__chevron').filter,
|
||||
count: style('.hm-sw-viewer-header__count').textShadow,
|
||||
icon: (() => {
|
||||
const icon = header.querySelector('.hm-sw-viewer-header__icon')
|
||||
const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *'))
|
||||
// lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units.
|
||||
return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) }
|
||||
})(),
|
||||
labelColor: style('.hm-sw-viewer-header__label').color,
|
||||
labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '),
|
||||
}
|
||||
@@ -351,7 +375,7 @@ if (comparison.reference) {
|
||||
const blacks = value => value.split('rgb(0, 0, 0)').length - 1
|
||||
check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60))
|
||||
check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60))
|
||||
check('icon outline: 4 x 1px black drop-shadows + #061A14 glow', [outline.icon, outline.chevron].every(value => (value.match(/drop-shadow/g) ?? []).length === 5 && value.includes('rgb(0, 0, 0) 1px 0px 0px') && value.includes('rgb(6, 26, 20)')), outline.icon.slice(0, 90))
|
||||
check('icon outline: SVG stroke copy, no CSS filter', outline.icon.filter === 'none' && outline.icon.copyStroke === 'rgb(0, 0, 0)' && outline.icon.copyWidth === outline.icon.glyphWidth + 3 && outline.icon.glyphStroke !== 'rgb(0, 0, 0)', JSON.stringify(outline.icon))
|
||||
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
|
||||
await page.close()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user