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:
shkim4 committed 2026-10-19 16:34:50 +09:00
1 parent 7f42923ccf
commit 68fbcf6e15
45 files changed
+1463 -377

No files matched your search

+31 -7
View File
@@ -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()
}