docs(sw): hover stability tests, AI rules and evidence for header / footer / home menu

Chromium and Firefox browser tests (hover position, field text, ellipsis and
tooltip), AI docs and guide, report with the open Firefox Continuous 0.6px
issue and the Figma difference of the home menu hover.
This commit is contained in:
shkim4 committed 2026-10-20 17:48:05 +09:00
1 parent 3f7cff5473
commit 032fcdcc03
19 files changed
+67942 -9

No files matched your search

@@ -56,7 +56,7 @@ function App() {
| 동작 | 결과 | 근거 |
|---|---|---|
| 로고 클릭 | 열기 / 다시 클릭하면 닫기 | Figma btn_home ON_CLICK → OVERLAY, 재클릭 닫기는 사용자 확정 |
| 타일 Hover | Variant2: 주황 gradient 1px 테두리, 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동 | Figma Default ON_HOVER → V2 |
| 타일 Hover | Variant2: 주황 gradient 1px 테두리, 아이콘·글자 `#FF5C00`. 아이콘·글자 위치·크기는 Default와 같음(2026-10-20 사용자 확정: Figma의 1px 위·왼쪽 이동은 적용하지 않음) | Figma Default ON_HOVER → V2 |
| 타일 클릭 | Variant3(Active, V2와 같은 모양) → 다시 클릭하면 Default | Figma V2 → V3 → Default |
| 메뉴 빈 곳 클릭 | 닫기 | Figma Group ON_CLICK → CLOSE (`closeOnBackgroundClick`) |
| Escape | 열린 패널을 먼저 닫고, 다시 누르면 메뉴를 닫은 뒤 로고로 Focus 복귀 | Figma 없음 · 접근성 (`closeOnEscape`) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+46
View File
@@ -0,0 +1,46 @@
# SW Header 필드 400 · Footer 말줄임 · Hover 위치 고정 (2026-10-20)
사용자 승인(2026-10-20) 범위의 수정·검증 기록이다. 증거: [hover-stability-qa](hover-stability-qa/) (`before/` = 수정 전 빌드, `after/` = 수정 후 빌드).
## 변경
| 영역 | 변경 | 기준 |
|---|---|---|
| Header 필드 값 | `.hm-sw-header__field-value` 700 → 400 | Figma 텍스트 속성 Dotum Regular 14 · 자간 0.56px. 레이어 스타일 이름 `Dotum/bold/14`는 겹침·외곽선 표현이며 Stroke·겹침은 복제하지 않음 |
| Footer 명령 라벨 | `text-overflow: clip` → `ellipsis`, `title`이 없으면 잘린 동안만 전체 라벨 Tooltip(Hover·Focus) | 68 × 47 칸·14px·라벨 이름 그대로, 접근성 이름 = 전체 라벨 또는 `ariaLabel`. 공개 Props 변경 없음 |
| Home Menu 타일 | Hover·Pressed·Active의 아이콘 `translate(-1px, -1px)`, 라벨 `top: 24px` 제거 | **사용자 최종 기준: Hover에서 아이콘·텍스트 이동 0px** |
## Figma와의 차이(기록, Figma 원본 변경 없음)
- Home Menu 타일 Variant2/Variant3은 Figma에서 아이콘·라벨이 1px 위·왼쪽으로 이동한다. 사용자 결정(2026-10-20)으로 구현은 이동하지 않는다. 색·Hover 아이콘·주황 테두리만 바뀐다. Figma 분석 기록([analysis-report.md](figma-additional/analysis-report.md))은 Figma 사실로 보존한다.
## 측정 결과
| 항목 | 수정 전 | 수정 후 |
|---|---|---|
| Home Menu 42타일(5개 Story 위치) Hover·Active | 아이콘 (-1, -1)px, 텍스트 Y -1px | 0px (크기·간격·타일 크기 동일) |
| Footer 명령 135칸(전체 Footer·도구 그룹·변경 예제 전 분류 + ▲ 패널) | 이동 0 | 이동 0 |
| `0_Layer_Cludmark` 텍스트/상자 (Chromium) | 142.97 / 142 (잘림) | 126.97 / 126.97 |
| 치수 `Standard` | 70.48 / 69 (잘림) | 62.48 / 62.48 |
| `POLYGON` · `WIPEOUT` | 잘린 글자(clip), Tooltip 없음 | `…` + Hover·Focus `title` |
| Header Open/More 520 ↔ 1920 (141단계) | — | 700과 400 배치 차이 0 |
SVG Hover 자산 25쌍(Home Menu 15, Footer 10)은 내부 도형 좌표가 Default와 같다(캔버스 8배 측정, 차이 0).
## 남은 문제
1. **Firefox `Continuous` 0.6px 잘림(미해결):** Firefox 158에서 선스타일 필드 첫 칸(`.hm-sw-header__line-values > :first-child`, `flex: 0 1 auto`)의 상자가 글자보다 0.6px 작게 계산되어 `Continuo…`로 표시된다(`hover-stability-qa/firefox-continuous/`). Chromium은 정상. 열 구조 grid/flex 변경, 자간·커닝·외곽선 조정으로는 해결되지 않았고, `flex: none`은 좁은 More 칸 말줄임을 깨고 끝 여백 추가는 Chromium 배치를 바꾼다. 사용자 지시로 이번에는 레이아웃 변경을 하지 않았으며, Chromium 반응형에 영향이 없는 수정안을 별도 검토한다. 참고: 수정 전(700) Firefox는 `0.25`·`1.00`·`0_Layer_Cludmark`가 잘렸다.
2. **`ByLayer`(가중치 38px 고정 칸):** 57.92px로 칸보다 길어 말줄임 + `title`. 사용자 결정으로 38px 칸·말줄임·Tooltip 유지.
## 검증
| 검사 | 결과 |
|---|---|
| `hover-stability.browser.mjs` (Chromium) | 201/201 PASS |
| `hover-stability-firefox.browser.mjs` (Firefox 158, BiDi) | 58/59 — FAIL 1 = 위 남은 문제 1 |
| footer-popup-viewer-refinement | 47/47 |
| integrated-improvement (Open 히스테리시스 포함) | 89 PASS / 0 FAIL |
| footer-design-review · theme-inheritance | 21/21 · 23/23 |
| home-menu-integration | 22/23 — 기존 실패(`without homeMenu…`, 이전 보고와 동일) |
| dropdown-consolidation | 223 PASS / 5 FAIL — 기존 스냅샷 실패(이전 보고와 동일) |
| `typecheck:sw` · `build:sw` · `build-storybook` · `check:ai-docs` · `ai-docs.check` 37/37 | PASS |
+1 -1
View File
@@ -104,7 +104,7 @@ import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번
- SVG·PNG 자산은 JS에 data URI로 포함되어 별도 설정이 없다.
- SW 기본 글꼴은 돋움(Dotum)이다(`--hm-sw-header-font-family: Dotum, "돋움", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`, Figma와 같음). 설치된 시스템 글꼴(Windows)을 쓰며 패키지는 글꼴 파일을 포함하지 않는다. 돋움이 없는 환경은 Malgun Gothic → Apple SD Gothic Neo로 대체된다. 앱이 별도 글꼴 파일을 로드할 필요는 없다.
- SW 화면은 `SWTheme`(또는 SW 컴포넌트 루트)와 SW 토큰의 글꼴을 그대로 쓴다. Web UI의 기본 글꼴(Noto Sans KR, `--hm-font-family-base`)이나 Web `theme.css`의 글꼴 설정을 SW 화면에 복사하지 않고, SW 컴포넌트에 `font-family`를 개별 지정하지 않는다. 프로그램(사용자)이 다른 글꼴을 정한 경우에만 §3.1 방법으로 `--hm-sw-header-font-family`를 바꾸고 결과 보고에 "글꼴 변경"으로 적는다.
- 돋움에는 Bold 글꼴이 없어 700은 브라우저가 합성 Bold로 그리며 글자당 약 1px 넓어진다. Figma 고정 폭 칸(예: 가중치·스케일 38px, 레이어 값, 문자/치수스타일)에 긴 값이 들어가면 말줄임이 된다.
- 돋움에는 Bold 글꼴이 없어 700은 브라우저가 합성 Bold로 그리며 글자당 약 1px 넓어진다. Header 필드 값은 Figma 텍스트 속성대로 400(Dotum Regular)이라 기본 값(`0_Layer_Cludmark`, `Standard`, `1.00` 등)이 잘리지 않는다(2026-10-20). Figma 고정 폭 칸(예: 가중치·스케일 38px)에 그보다 긴 값(예: `ByLayer`)이 들어가면 말줄임 + `title`이 된다. Figma 레이어 이름의 `Dotum/bold/14`를 보고 700으로 바꾸거나 겹침·외곽선을 따로 만들지 않는다.
- CSS는 전역(`html`, `body`, `*`)을 건드리지 않는다. `body` margin 등 페이지 기본값은 소비 앱이 정한다.
- 스크롤바: SW 전용 스크롤바 디자인은 없다. 기존 SW 컴포넌트는 브라우저 기본 스크롤바를 쓰며(속성Bar만 `scrollbar-width: thin`), Popup 본문 스크롤은 `SWPopup`이 처리한다. 새 스크롤 영역에 임의 스크롤바 색·두께를 만들지 않는다.
- TypeScript에서 CSS side-effect import 오류가 나면 Vite의 `vite/client` 타입을 포함한다.
+4 -4
View File
@@ -46,7 +46,7 @@
|---|---|
| 글꼴 | 돋움(Dotum) → Malgun Gothic → Apple SD Gothic Neo (`--hm-sw-header-font-family: Dotum, "돋움", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`, Figma와 같음, 2026-10-20). 설치된 시스템 글꼴을 쓰며 글꼴 파일은 포함하지 않음. Web UI 기본 글꼴(Noto Sans KR)과 별개. 다른 글꼴은 프로그램이 정한 경우에만 [AI_USAGE.md](./AI_USAGE.md) §3.1 방법으로 변경. 돋움 700은 합성 Bold(글자당 약 +1px) |
| 기본 / 라벨 | 14px / 12px (`--hm-sw-header-font-size` / `--hm-sw-header-label-size`) |
| Weight | 400, 500, 700만 사용(600 없음). 값·제목·토글 700 |
| Weight | 400, 500, 700만 사용(600 없음). Header 필드 값(레이어·색상·선스타일·가중치·스케일·문자/치수스타일 값)은 400(Figma Dotum Regular 14 · 자간 0.56px, 2026-10-20), 제목·토글·버튼·메뉴 글자 700 |
| 자간 | 값 0.04em, 라벨 0.02em, 파일명 0.06em |
| Text Stroke | Figma Stroke가 있는 텍스트만 검정 외곽선: 14·16px `--hm-sw-header-text-outline`(1.3px), 12px `--hm-sw-header-label-outline`(1px). 자기 색 Stroke 없음. More·Tooltip·`+ New`/`Open` 제외 |
| 말줄임 | 값은 한 줄 `ellipsis`, `title`로 전체 표시 |
@@ -169,7 +169,7 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 크기 | 메뉴 높이 77(섹션 75 + 하단 강조 2), 그룹 46, 타일 80 × 46, 아이콘 18 슬롯, 섹션 폭은 타일 수로 자동 | — |
| 섹션 면 | 세로 gradient 면 + 1px `#081813` + 안쪽 1px `#20190E`, 아래 모서리 3px | `--hm-sw-home-menu-section-*` |
| 섹션 제목 | Figma tit_* badge(9-slice), 글자 400 검정 | `--hm-sw-home-menu-title-color` |
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Pressed(누르는 동안)·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동. 라벨 700. 최근파일 외 항목은 실행 버튼: 클릭 후 ON 유지 없음(2026-10-19 사용자 확정). Active 유지는 하위 패널 타일(최근파일, 목록이 열린 동안)과 더보기·홈 버튼(메뉴가 열린 동안)만. 유지형이 필요하면 `toggle: true` | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Pressed(누르는 동안)·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`. 아이콘·라벨의 위치·크기·간격과 타일 크기는 Default와 같음(2026-10-20 사용자 확정, Figma의 1px 위·왼쪽 이동은 적용하지 않음). 라벨 700. 최근파일 외 항목은 실행 버튼: 클릭 후 ON 유지 없음(2026-10-19 사용자 확정). Active 유지는 하위 패널 타일(최근파일, 목록이 열린 동안)과 더보기·홈 버튼(메뉴가 열린 동안)만. 유지형이 필요하면 `toggle: true` | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
| 하단 강조 | `sections` = 섹션마다 주황 띠, `outline` = 메뉴 전체 2px 외곽선(하위 패널이 열린 동안, `auto` 기본) | `--hm-sw-home-menu-accent-*` |
| 최근파일 패널 | 폭 354, 행 50, 배경 `#27241D`, 주황 테두리, 행 Hover `#62533D` + `#FF5C00`, 미리보기 310 × 270. 미리보기 위치: 목록 오른쪽(Figma) → 오른쪽에 공간이 없으면 왼쪽 → 좌우 모두 없으면(예: 390) 목록 위에 겹쳐 Hover 행 바로 아래(목록 왼쪽 1px 안쪽, 4면 테두리·radius 4, 크기·색 동일), 화면 아래 공간이 없으면 행 바로 위(2026-10-19 사용자 확정, Figma 없음). 가로 스크롤 없음 | `--hm-sw-recent-files-*` |
| 동작 | 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기. 메뉴 빈 곳 클릭 닫기(Figma CLOSE). Escape: 하위 패널 → 메뉴, 닫히면 로고로 Focus. 메뉴 밖 클릭은 닫지 않음(Figma overlay 기준) | — |
@@ -190,8 +190,8 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 보조기능 | 104 × 33, gap 2, radius 3, 면 `linear-gradient(0deg, #262218 8%, #4A3C2B 34%, #594C31 55%, #312A1B 90%)`, 아이콘 중심 (19.5, 16.5), 라벨 13px 왼쪽 36 폭 57 가운데. 테두리 1px 그라데이션(위 → 아래): Default `#886D35 0%, #423625 8.85%, #F3DBA8 26.17%, #0E0B06 83.79%, #574B30 100%`(Figma btn_* 26:9248 …) | `--hm-sw-footer-aux-*`, `--hm-sw-footer-aux-border` |
| 보조기능 상태 | Hover(선택 아님) = 테두리만 노랑 `--hm-sw-header-hover-border-gradient`(`#A67D03 → #F2CC0F → #FADD4F → #F2CC0F → #926E03`, SW Header Figma Hover 재사용 — 버튼 세트에는 Hover 없음, 사용자 확정 2026-10-20). Selected(`aria-pressed="true"`, Figma Variant2 = Variant3) = 테두리 `#DA6813 8.52%, #FFBB89 37.93%, #E5690D 71.85%, #AE3607 92.77%`(Figma `gra-border-orange` = `--hm-sw-footer-gra-border-orange`) + 아이콘 채움·글자 `#FF5C00`, Hover보다 우선. 아이콘 외곽선 검정 90% 1px는 모든 상태 동일 | `--hm-sw-footer-aux-hover-border`, `--hm-sw-footer-aux-pressed-border`, `--hm-sw-footer-aux-icon-color`(`#F1EDE5`) |
| 명령 그룹 면 | 테두리 위·왼쪽·아래 1px `#886D35`, 왼쪽 위 radius 4, 면 `linear-gradient(0deg, #2F281A 0%, #312A1B 5%, #332B1C 36%, #463C29 58%, #38301F 79%, #19160E 98%)`. 마지막 명령 뒤(빈 칸·나머지)는 bg_vacant `linear-gradient(0deg, #18150E 28%, #262015 41%, #332B1C 58%, #38301F 81%, #18150E 100%)`, 명령 칸 높이까지만(2026-10-20: 분류 행 아래로 내려가지 않아 선택 탭 Pocket 면이 어느 탭에서나 같음) | `--hm-sw-footer-group-*`, `--hm-sw-footer-vacant-background` |
| 명령 칸 | 68 × 47, 라벨 14px `#F6F4F2` 위 6, 아이콘 18 × 18 위 26 가운데. 칸 사이 구분선 1 × 37(위 5) `linear-gradient(0deg, #20190E, #8A7A5B 45%, #F3DBA8 70%, #423625)` + `-1px 0 1px #000` | `--hm-sw-footer-slot-*`, `--hm-sw-footer-divider*` |
| 명령 상태 | Hover = 선택(Figma Variant2 = Variant3): 글자 `#FF5C00` + 주황 아이콘. Disabled 40%(Figma 없음) | `--hm-sw-footer-accent` |
| 명령 칸 | 68 × 47, 라벨 14px `#F6F4F2` 위 6(좌우 2, 넘치면 `…` 말줄임 — 칸·글자 크기 그대로, 2026-10-20), 아이콘 18 × 18 위 26 가운데. 칸 사이 구분선 1 × 37(위 5) `linear-gradient(0deg, #20190E, #8A7A5B 45%, #F3DBA8 70%, #423625)` + `-1px 0 1px #000` | `--hm-sw-footer-slot-*`, `--hm-sw-footer-divider*` |
| 명령 상태 | Hover = 선택(Figma Variant2 = Variant3): 글자 `#FF5C00` + 주황 아이콘. 색·아이콘만 바뀌고 아이콘·라벨 위치·크기·간격과 칸 크기는 그대로. Disabled 40%(Figma 없음) | `--hm-sw-footer-accent` |
| 빈 칸 | 68 × 46(위 1), + `#BEB5A6`. Hover: 주황 + 와 주황 그라데이션 점선 링(자산) | — |
| 가이드 | 208 × 149, 빈 칸 위 4px 가운데, 1px `#DA6813` radius 2, 면 `#0E3C2E`, 미리보기 194 × 111(5, 5, 검정 1px), 제목 14px 외곽선, 캡션 12px `#B6D0C9` 1px 외곽선 | `--hm-sw-footer-guide-*` |
| 분류 행 | 높이 24, 면 `#282216` → 위 14% `#574B30`, 안쪽 1px 테두리 그라데이션, 현재 탭 Pocket(좌우 7px 곡선, 아래 radius 2, 가장자리 `#675A3B`). 탭 14px `#D4CBBD`, 자간 10%, 위 4, 좌우 padding 8, 최소 43. 구분선 1 × 10 검정(위 7). Hover·현재 `#FF5C00` | `--hm-sw-footer-menu-*` |
+3 -3
View File
@@ -52,7 +52,7 @@ import "@hm-design/sw/styles.css"
| `trailing` | 없음 | 바 오른쪽 빈 영역 콘텐츠(좌표·상태) |
| `label` | `명령 도구` | `<footer>` 접근성 이름 |
`SWFooterTool`: `{ id, label, icon?, activeIcon?, title?, ariaLabel?, disabled? }`. `icon` = 기본(18 × 18), `activeIcon` = Hover·선택 아이콘(없으면 `icon` 유지). 줄인 라벨(`Rect...`)은 `ariaLabel`로 전체 이름을 줍니다. Figma 명령 아이콘은 `swFooterToolIcons.line / pline / arc / circle / ellipse / rect / poly / point / hatch / ncopy`(각 `{ icon, activeIcon }`), 보조기능 아이콘은 `swFooterAuxIcons.objectSnap / selectionEffect / orthogonal / gridSnap / gridBackground / lineWeight`(인라인 SVG, 채움 `currentColor`: 버튼 상태 색을 그대로 받음). 프로그램이 직접 넣는 보조기능 아이콘도 `currentColor`로 그리면 Selected 주황이 적용됩니다(고정 색 `<img>`는 색이 바뀌지 않음).
`SWFooterTool`: `{ id, label, icon?, activeIcon?, title?, ariaLabel?, disabled? }`. `icon` = 기본(18 × 18), `activeIcon` = Hover·선택 아이콘(없으면 `icon` 유지). 줄인 라벨(`Rect...`)은 `ariaLabel`로 전체 이름을 줍니다. 칸보다 긴 라벨(`POLYGON`, `WIPEOUT` …)은 68 × 47 칸과 14px 글자 그대로 `…`로 줄고, `title`이 없으면 잘린 동안만 전체 라벨이 Hover·Focus Tooltip(native `title`)으로 나옵니다(접근성 이름은 항상 전체 라벨 또는 `ariaLabel`). 라벨을 임의로 줄이거나(`Poly`) 칸·글자 크기를 바꾸지 않습니다. Figma 명령 아이콘은 `swFooterToolIcons.line / pline / arc / circle / ellipse / rect / poly / point / hatch / ncopy`(각 `{ icon, activeIcon }`), 보조기능 아이콘은 `swFooterAuxIcons.objectSnap / selectionEffect / orthogonal / gridSnap / gridBackground / lineWeight`(인라인 SVG, 채움 `currentColor`: 버튼 상태 색을 그대로 받음). 프로그램이 직접 넣는 보조기능 아이콘도 `currentColor`로 그리면 Selected 주황이 적용됩니다(고정 색 `<img>`는 색이 바뀌지 않음).
## 3. 부품 (조합용, `<SWTheme>` 안)
@@ -61,7 +61,7 @@ import "@hm-design/sw/styles.css"
| `SWFooterSideButton` | 74 × 71 | `title`(필수), `subtitle`, `side`(`start`/`end`, 그림자 방향), `tooltip`, `button` 속성 |
| `SWFooterAuxGroup` | 104 × 33 칸, gap 2 | `items`(필수), `onPressedChange`, `onItemClick`, `columns`(3), `label` |
| `SWFooterAuxButton` | 104 × 33 | `label`(필수), `icon`, `pressed`, `button` 속성 |
| `SWFooterToolButton` | 68 × 47 칸 | `label`(필수), `icon`, `activeIcon`, `pressed`, `button` 속성. 연속 배치하면 칸 사이 1px 금색 구분선 |
| `SWFooterToolButton` | 68 × 47 칸 | `label`(필수), `icon`, `activeIcon`, `pressed`, `button` 속성(`title` 생략 시 잘린 라벨만 자동 Tooltip). 연속 배치하면 칸 사이 1px 금색 구분선 |
| `SWFooterAddSlot` | 68 × 47 칸(+ 68 × 46) | `guide`(기본 Figma 가이드 / `false`), `guideOpen`(Controlled), `onGuideOpenChange`, `button` 속성 |
| `SWFooterGuide` | 208 × 149 | `title`, `caption`, `media`, `div` 속성. 단독 카드로 둘 때 `data-standalone=""` |
| `SWFooterMenu` | 높이 24 | `items`(`{ id, label, disabled? }[]`, 필수), `value`(필수), `onValueChange`, `onAdd`, `addLabel`, `label`, `panelId`, `tabIdPrefix`. 현재 탭은 위 칸과 이어지는 Pocket 모양(SVG, 위치 자동 계산) |
@@ -77,7 +77,7 @@ import "@hm-design/sw/styles.css"
| 요소 | Figma | 구현 |
|---|---|---|
| 명령 | Default `#F6F4F2` / Variant2 Hover = Variant3 선택: `#FF5C00` + 주황 아이콘 | Hover는 CSS, 선택은 `activeToolId`·`pressed` |
| 명령 | Default `#F6F4F2` / Variant2 Hover = Variant3 선택: `#FF5C00` + 주황 아이콘 | Hover는 CSS, 선택은 `activeToolId`·`pressed`. 아이콘·라벨 위치·크기와 칸 크기는 모든 상태에서 같음 |
| 분류 탭 | Default `#D4CBBD` / Hover·현재 `#FF5C00`, 현재 탭은 Pocket | `categoryId`·`value` |
| 빈 칸 | Default + / Hover 주황 점선 링 + 주황 + | Hover·Focus 시 `slotGuide` 표시 |
| Layout 탭 | Default 베이지 `#C3B79E` / Hover(선택 아님, ×) = Model과 같은 주황 그라데이션 면(글자는 Default와 동일: 검정 `#161410`, 크기·굵기·위치 불변) / On 갈색 그라데이션·흰 글자 / On hover / Variant5 이름 편집(`#0F0D0A`, 빨강 `#E60403`) / Variant6 편집 + Hover | `value`, 편집 = 더블클릭·F2 또는 `editingId` |
@@ -0,0 +1,123 @@
// 2026-10-20 Firefox counterpart of hover-stability.browser.mjs: the installed Firefox over WebDriver BiDi (no
// Playwright build), real pointer moves. Home Menu tiles and the full-footer commands: icon / text / box are the same
// at rest, on hover and while pressed; header field values are Dotum 400 and not cut; POLYGON / WIPEOUT ellipsis.
// Usage: node packages/sw/tests/hover-stability-firefox.browser.mjs <evidence-dir> [storybook-url]
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
const out = path.resolve(process.argv[2] ?? 'docs/sw/hover-stability-qa')
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
const VIEW = { width: 1920, height: 1100 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
const profile = path.join(os.tmpdir(), `sw-ff-hover-${process.pid}`)
await mkdir(profile, { recursive: true })
const port = 9300 + (process.pid % 500)
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
let ws
for (let i = 0; i < 60 && !ws; i++) {
await sleep(500)
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
}
if (!ws) throw new Error('Firefox BiDi not reachable')
let id = 0; const pending = new Map(), errors = []
ws.onmessage = ev => {
const m = JSON.parse(ev.data)
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
else if (m.method === 'log.entryAdded' && m.params.level === 'error') errors.push(m.params.text)
}
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + ' -> ' + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
const session = await send('session.new', { capabilities: {} })
await send('session.subscribe', { events: ['log.entryAdded'] })
const context = (await send('browsingContext.getTree', {})).contexts[0].context
// The headless window may ignore MOZ_HEADLESS_WIDTH; ask for the viewport and fall back to what the window gives.
try { await send('browsingContext.setViewport', { context, viewport: VIEW }) } catch (e) { console.log('INFO setViewport:', e.message) }
const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 120)); return r.result.value }
const goto = async story => {
await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' })
for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('#storybook-root > *')`)) break; await sleep(250) }
await ev('document.fonts.ready.then(() => true)'); await sleep(400)
}
const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] })
const move = (x, y) => pointer([{ type: 'pointerMove', x: Math.round(x), y: Math.round(y), duration: 0 }])
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 500)) }
const SNAP = `(sel, i, q) => { const el = [...document.querySelectorAll(sel)].filter(e => e.getClientRects().length)[i]
const vis = e => { for (let n = e; n && n !== el.parentElement; n = n.parentElement) { const c = getComputedStyle(n); if (c.display === 'none' || c.visibility === 'hidden') return false } return true }
const r = e => { const b = e.getBoundingClientRect(); return [b.x, b.y, b.width, b.height].map(v => Math.round(v * 1000) / 1000) }
const art = [...el.querySelectorAll(q.icon)].filter(vis).map(i => i.querySelector('img, svg') ?? i)
const label = el.querySelector(q.label), range = document.createRange(); range.selectNodeContents(label)
const t = range.getBoundingClientRect()
return JSON.stringify({ name: label.textContent.trim(), box: r(el), icon: art.length === 1 ? r(art[0]) : null, n: art.length, text: [t.x, t.y, t.width, t.height].map(v => Math.round(v * 1000) / 1000), color: getComputedStyle(label).color, active: el.dataset.active === 'true' }) }`
const snap = async (sel, i, q) => JSON.parse(await ev(`(${SNAP})(${JSON.stringify(sel)}, ${i}, ${JSON.stringify(q)})`))
// Inside the item (the page may scroll between reads: a scroll moves the whole item, not its content).
const rd = v => Math.round(v * 1000) / 1000
const geo = s => JSON.stringify([s.box[2], s.box[3], s.icon && [rd(s.icon[0] - s.box[0]), rd(s.icon[1] - s.box[1]), s.icon[2], s.icon[3]], s.n, [rd(s.text[0] - s.box[0]), rd(s.text[1] - s.box[1]), s.text[2], s.text[3]]])
async function cycleAll(sel, q, tag) {
const n = await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length).length`)
const results = []
for (let i = 0; i < n; i++) {
await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length)[${i}].scrollIntoView({ block: 'center', inline: 'center' }), true`); await sleep(60)
await move(1, 1); await sleep(80)
const d0 = await snap(sel, i, q)
const [x, y] = [d0.box[0] + d0.box[2] / 2, d0.box[1] + d0.box[3] / 2]
await move(x, y); await sleep(100); const h1 = await snap(sel, i, q)
await move(1, 1); await sleep(100); const d1 = await snap(sel, i, q)
await move(x, y); await sleep(100); const h2 = await snap(sel, i, q)
await pointer([{ type: 'pointerDown', button: 0 }]); await sleep(80); const a = await snap(sel, i, q)
await pointer([{ type: 'pointerMove', x: 1, y: 1, duration: 0 }, { type: 'pointerUp', button: 0 }]); await sleep(100)
const same = [h1, d1, h2, a].every(s => geo(s) === geo(d0))
results.push({ name: d0.name, same, hovered: d0.active || h1.color !== d0.color, d0, h1, a })
check(`Firefox ${tag} "${d0.name}": icon · text · box unchanged (Default/Hover/Default/Hover/Active)`, same, { d0: geo(d0), h1: geo(h1), a: geo(a) })
}
return results
}
const report = { browser: session.capabilities.browserVersion }
await goto('sw-header--interactive-states')
report.viewport = JSON.parse(await ev('JSON.stringify([innerWidth, innerHeight])'))
console.log('INFO Firefox', report.browser, 'viewport', report.viewport.join(' x '))
report.headerFields = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('.hm-sw-header')].filter(h => h.getBoundingClientRect().width >= 1800).flatMap(h => [...h.querySelectorAll('.hm-sw-header__field-value')].filter(e => e.getClientRects().length).map(e => { const c = getComputedStyle(e), g = document.createRange(); g.selectNodeContents(e); const tw = g.getBoundingClientRect().width, bw = e.getBoundingClientRect().width; return { text: e.textContent, field: e.closest('[data-menu]')?.dataset.menu, weight: c.fontWeight, textW: Math.round(tw * 100) / 100, boxW: Math.round(bw * 100) / 100, cut: tw > bw + 0.05 } })))`))
// Fixed 38px 가중치 / 스케일 slots hold short numbers; a longer word there is a data-length case (see the Chromium test).
check('Firefox Header: field values are 400 and not cut (Standard, 0_Layer_Cludmark, Continuous, 1.00, 255,229,0 …)', report.headerFields.length >= 7 && report.headerFields.every(f => f.weight === '400' && (!f.cut || (f.field === 'line-style' && f.boxW <= 38))), report.headerFields.filter(f => f.weight !== '400' || f.cut))
await goto('sw-header--home-menu')
report.home = await cycleAll('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item', { icon: '.hm-sw-home-menu__icon', label: '.hm-sw-home-menu__label' }, 'Home Menu')
check(`Firefox Home Menu: ${report.home.length} tiles, hover reached every non-active tile`, report.home.length >= 15 && report.home.every(r => r.hovered), report.home.filter(r => !r.hovered).map(r => r.name))
await send('browsingContext.captureScreenshot', { context }).then(r => writeFile(path.join(out, 'home-menu-firefox.png'), Buffer.from(r.data, 'base64')))
await goto('sw-footer--full-footer')
const FOOT = '.sw-footer-story-workspace .hm-sw-footer-tool'
report.footer = []
const tabs = await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab').length`)
for (let k = 0; k < tabs; k++) {
await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab')[${k}].click(), true`); await sleep(120)
report.footer.push(...await cycleAll(FOOT, { icon: '.hm-sw-footer-tool__icon', label: '.hm-sw-footer-tool__label' }, `Footer tab ${k + 1}`))
}
check(`Firefox Footer: ${report.footer.length} commands, all 68 × 47, hover reached every command`, report.footer.length >= 11 && report.footer.every(r => r.d0.box[2] === 68 && r.d0.box[3] === 47 && r.hovered), report.footer.filter(r => !r.hovered).map(r => r.name))
await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab')[0].click(), true`); await sleep(120)
report.ellipsis = {}
for (const name of ['POLYGON', 'WIPEOUT']) {
const box = JSON.parse(await ev(`(() => { const b = [...document.querySelectorAll('${FOOT}')].find(e => e.textContent === '${name}'); b.scrollIntoView({ block: 'center', inline: 'center' }); const r = b.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
await move(1, 1); await sleep(60); await move(box[0], box[1]); await sleep(150)
report.ellipsis[name] = JSON.parse(await ev(`(() => { const b = [...document.querySelectorAll('${FOOT}')].find(e => e.textContent === '${name}'), l = b.querySelector('.hm-sw-footer-tool__label'); return JSON.stringify({ overflow: getComputedStyle(l).textOverflow, cut: l.scrollWidth > l.clientWidth, title: b.getAttribute('title'), w: b.getBoundingClientRect().width, h: b.getBoundingClientRect().height }) })()`))
const e = report.ellipsis[name]
check(`Firefox Footer: ${name} ends in … (cut) and its hover title is the full name, 68 × 47`, e.overflow === 'ellipsis' && e.cut && e.title === name && e.w === 68 && e.h === 47, e)
}
await move(1, 1)
await send('browsingContext.captureScreenshot', { context }).then(r => writeFile(path.join(out, 'footer-firefox.png'), Buffer.from(r.data, 'base64')))
// The Storybook UI font (sb-common-assets / Nunito Sans) is not part of the SW page; it fails only on this static server.
const swErrors = errors.filter(e => !e.includes('sb-common-assets'))
report.environmentErrors = errors.length - swErrors.length
check('Firefox: no console errors (Storybook UI font requests excluded)', swErrors.length === 0, swErrors)
await writeFile(path.join(out, 'hover-stability-firefox.json'), JSON.stringify({ checks, report }, null, 2))
const failed = checks.filter(c => !c.ok).length
console.log(`${checks.length - failed}/${checks.length} PASS (firefox ${report.browser})`)
try { await send('session.end', {}) } catch {}
ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {})
process.exitCode = failed ? 1 : 0
@@ -0,0 +1,239 @@
// 2026-10-20 SW hover position stability + header field text + footer command ellipsis / tooltip.
// Hover (and Active / Selected) may change only color, background and the hover ring: the icon, the text, their gap
// and the item box stay where they are (Header Home Menu tiles, Footer command slots in every story and category,
// including the ▲ panel). Also: Header field values are Dotum 400 and not cut; long footer commands end in an
// ellipsis inside the unchanged 68 × 47 slot and show their full name on hover.
// Usage: build Storybook first, then node packages/sw/tests/hover-stability.browser.mjs [evidence-dir] [chromium|firefox]
import { createRequire } from 'node:module'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const playwright = require('playwright')
const out = path.resolve(process.argv[2] ?? 'docs/sw/hover-stability-qa'); await mkdir(out, { recursive: true })
const engine = process.argv[3] ?? 'chromium'
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', '.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)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`
const browser = engine === 'firefox'
? await playwright.firefox.launch({ headless: true })
: await playwright.chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 600)) }
const report = { engine }
let errors = []
const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } })
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(300) }
// Sub-pixel rounding (e.g. a centered odd-width box) is not a layout move: positions are compared to 0.01px.
const EPS = 0.01
// Geometry of one item: the box, the visible icon art (the <img> / <svg> inside the shown icon slot) and the text
// (glyph run through a Range, so a padding or position change of the text box is caught too).
const SNAP = `(el, q) => {
const vis = e => { for (let n = e; n && n !== el.parentElement; n = n.parentElement) { const c = getComputedStyle(n); if (c.display === 'none' || c.visibility === 'hidden') return false } return true }
const r = e => { const b = e.getBoundingClientRect(); return [b.x, b.y, b.width, b.height].map(v => Math.round(v * 1000) / 1000) }
const icons = [...el.querySelectorAll(q.icon)].filter(vis)
const art = icons.map(i => i.querySelector('img, svg') ?? i)
const label = el.querySelector(q.label)
const range = document.createRange(); range.selectNodeContents(label)
const t = range.getBoundingClientRect(), lc = getComputedStyle(label)
const text = [t.x, t.y, t.width, t.height].map(v => Math.round(v * 1000) / 1000)
const icon = art.length === 1 ? r(art[0]) : null
return {
box: r(el), icon, iconCount: art.length, iconSrc: art[0]?.getAttribute?.('src')?.slice(-40) ?? art[0]?.tagName,
text, labelBox: r(label),
// vertical gap between the icon art and the text run (both slots stack vertically)
gap: icon ? Math.round((icon[1] > text[1] ? icon[1] - (text[1] + text[3]) : text[1] - (icon[1] + icon[3])) * 1000) / 1000 : null,
font: [lc.fontFamily.split(',')[0], lc.fontSize, lc.fontWeight, lc.lineHeight, lc.letterSpacing].join('|'),
color: lc.color, iconTransform: icons[0] ? getComputedStyle(icons[0]).transform : null,
}
}`
const snap = (loc, q) => loc.evaluate(new Function('return ' + SNAP)(), q)
const geom = s => JSON.stringify({ box: s.box, icon: s.icon, n: s.iconCount, text: s.text, labelBox: s.labelBox, gap: s.gap, font: s.font })
const delta = (a, b) => {
const d = {}
for (const k of ['box', 'icon', 'text', 'labelBox']) if (a[k] && b[k]) { const v = a[k].map((x, i) => Math.round((b[k][i] - x) * 1000) / 1000); if (v.some(x => Math.abs(x) > EPS)) d[k] = v }
if (a.gap !== null && b.gap !== null && Math.abs(a.gap - b.gap) > EPS) d.gap = [a.gap, b.gap]
if (a.font !== b.font) d.font = [a.font, b.font]
if (a.iconCount !== b.iconCount) d.iconCount = [a.iconCount, b.iconCount]
return d
}
const away = async () => { await page.mouse.move(2, 2); await page.waitForTimeout(90) }
const center = async loc => { const b = await loc.boundingBox(); return [b.x + b.width / 2, b.y + b.height / 2] }
// Default → Hover → Default → Hover → Active (held, released outside so nothing is clicked).
async function cycle(loc, q) {
await loc.scrollIntoViewIfNeeded(); await away(); const d0 = await snap(loc, q)
const [x, y] = await center(loc)
await page.mouse.move(x, y); await page.waitForTimeout(90); const h1 = await snap(loc, q)
await away(); const d1 = await snap(loc, q)
await page.mouse.move(x, y); await page.waitForTimeout(90); const h2 = await snap(loc, q)
await page.mouse.down(); await page.waitForTimeout(60); const a = await snap(loc, q)
await page.mouse.move(2, 2); await page.mouse.up(); await page.waitForTimeout(90)
const d2 = await snap(loc, q)
return { d0, h1, d1, h2, a, d2 }
}
function judge(name, c) {
const moves = { hover: delta(c.d0, c.h1), back: delta(c.d0, c.d1), hover2: delta(c.d0, c.h2), active: delta(c.d0, c.a), end: delta(c.d0, c.d2) }
const moved = Object.fromEntries(Object.entries(moves).filter(([, v]) => Object.keys(v).length))
return { name, ok: Object.keys(moved).length === 0, moved, colorChanged: c.d0.color !== c.h1.color, iconSwap: c.d0.iconSrc !== c.h1.iconSrc, default: c.d0, hover: c.h1, active: c.a }
}
// ---------- 1. Header field values: Dotum 400, nothing cut ----------
// Full-width headers (1800 / 1920px) show every field; narrower ones move the aux fields into More.
const fields = []
for (const id of ['sw-header--interactive-states', 'sw-header--document-open', 'sw-header--responsive-more']) {
await goto(id)
const wide = page.locator('.hm-sw-header').filter({ visible: true })
for (let i = 0, n = await wide.count(); i < n; i++) {
const h = wide.nth(i), w = await h.evaluate(e => e.getBoundingClientRect().width)
if (w < 1800) continue
for (const f of await h.locator('.hm-sw-header__field-value').evaluateAll(list => list.filter(e => e.getClientRects().length).map(e => { const c = getComputedStyle(e), range = document.createRange(); range.selectNodeContents(e); return { text: e.textContent, field: e.closest('[data-menu]')?.dataset.menu, weight: c.fontWeight, size: c.fontSize, spacing: c.letterSpacing, family: c.fontFamily.split(',')[0], textW: Math.round(range.getBoundingClientRect().width * 100) / 100, boxW: Math.round(e.getBoundingClientRect().width * 100) / 100, client: e.clientWidth, scroll: e.scrollWidth, cut: range.getBoundingClientRect().width > e.getBoundingClientRect().width + 0.05 } }))) fields.push({ story: id, header: i, width: w, ...f })
}
}
report.headerFields = fields
check('Header: every field value is Dotum 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /dotum/i.test(f.family)), fields)
for (const want of ['Standard', '0_Layer_Cludmark', '1.00', '255,229,0']) {
const f = fields.filter(x => x.text === want)
check(`Header: "${want}" is shown whole (text width ≤ box)`, f.length > 0 && f.every(x => !x.cut), f)
}
// The 가중치 / 스케일 slots of the line-style field are a fixed 38px (Figma, numbers such as 0.25): a word such as
// ByLayer there is a data-length case (ellipsis + title), reported apart, not a weight / cut regression.
const fixedSlot = f => f.field === 'line-style' && f.client <= 38
check('Header: no field value is cut in a full-width (≥ 1800px) header (except fixed 38px 가중치 / 스케일 slots)', fields.filter(f => !fixedSlot(f)).every(f => !f.cut), fields.filter(f => f.cut && !fixedSlot(f)))
report.fixedSlotCut = [...new Set(fields.filter(f => f.cut && fixedSlot(f)).map(f => `${f.text} (${f.textW} / ${f.client}px)`))]
console.log('INFO fixed 38px slot values longer than the slot:', report.fixedSlotCut.join(', ') || 'none')
await goto('sw-header--interactive-states')
const others = await page.locator('.hm-sw-header :is(.hm-sw-header__menu-text, .hm-sw-header__font-name, .hm-sw-header__document-name)').evaluateAll(l => [...new Set(l.filter(e => e.getClientRects().length).map(e => getComputedStyle(e).fontWeight))])
check('Header: buttons / menu text / font sample keep their own weight (700)', others.every(w => w === '700'), others)
// ---------- 2. Header Home Menu tiles ----------
const HQ = { icon: '.hm-sw-home-menu__icon', label: '.hm-sw-home-menu__label' }
await goto('sw-header--home-menu')
const tiles = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item').filter({ visible: true })
const tileCount = await tiles.count()
const homeResults = []
for (let i = 0; i < tileCount; i++) {
const t = tiles.nth(i)
if (await t.isDisabled()) continue
const name = (await t.locator(HQ.label).textContent()).trim()
// A panel tile (최근파일) stays Active after a click; the cycle releases outside, so it never opens.
const activeAtRest = await t.getAttribute('data-active') === 'true'
homeResults.push({ ...judge(name, await cycle(t, HQ)), activeAtRest })
}
report.homeMenu = homeResults
check(`Home Menu: tiles measured (${homeResults.length})`, homeResults.length >= 15, homeResults.map(r => r.name))
for (const r of homeResults) check(`Home Menu "${r.name}": icon · text · gap · box unchanged (Default/Hover/Default/Hover/Active)`, r.ok, r.moved)
check('Home Menu: hover still shows the accent (#FF5C00 text, hover icon, orange ring)', homeResults.filter(r => !r.activeAtRest).every(r => r.hover.color === 'rgb(255, 92, 0)' && r.default.color !== r.hover.color), homeResults.map(r => [r.name, r.default.color, r.hover.color]))
// Active tiles (shown Active by a Story) have the same icon / text place inside the tile as Default tiles.
const rel = s => [s.icon[0] - s.box[0], s.icon[1] - s.box[1], s.text[0] - s.box[0], s.text[1] - s.box[1]].map(v => Math.round(v * 1000) / 1000).join(',')
const activeRel = homeResults.filter(r => r.activeAtRest).map(r => { const d = homeResults.find(x => !x.activeAtRest && x.name === r.name); return { name: r.name, active: rel(r.default), def: d && rel(d.default) } })
report.homeActiveRelative = activeRel
check(`Home Menu: Active tiles (${activeRel.length}) = Default tiles: same icon / text offset inside the tile`, activeRel.length > 0 && activeRel.every(a => a.def && a.active === a.def), activeRel)
const ring = await tiles.first().evaluate(async e => { const before = getComputedStyle(e, '::after').opacity; return before })
check('Home Menu: ring hidden at rest', ring === '0', ring)
// Keyboard focus / Escape still work on the menu
await tiles.first().focus(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(60)
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim())
check('Home Menu: ArrowRight moves focus to the next tile', focused === homeResults[1]?.name, focused)
await page.screenshot({ path: path.join(out, `home-menu-${engine}.png`), clip: { x: 0, y: 0, width: 1920, height: 220 } })
// ---------- 3. Footer command slots: every story, every category with commands, ▲ panel ----------
const FQ = { icon: '.hm-sw-footer-tool__icon', label: '.hm-sw-footer-tool__label' }
const footerResults = []
async function footerTools(story, scope, tag) {
const tools = scope.locator('.hm-sw-footer-tool:visible')
const n = await tools.count()
for (let i = 0; i < n; i++) {
const t = tools.nth(i)
if (await t.isDisabled()) continue
const name = (await t.locator(FQ.label).textContent()).trim()
const r = judge(`${story} · ${tag} · ${name}`, await cycle(t, FQ))
// Selected (pressed) — the same geometry as Default
r.label = name
footerResults.push(r)
}
return n
}
const STORIES = ['sw-footer--full-footer', 'sw-footer--tool-groups', 'sw-footer--custom-content']
for (const story of STORIES) {
await goto(story)
const footers = page.locator('.hm-sw-footer:visible')
const fc = await footers.count()
for (let f = 0; f < fc; f++) {
const footer = footers.nth(f)
const tabs = footer.locator('.hm-sw-footer-menu__tab')
const tc = await tabs.count()
if (tc === 0) { await footerTools(story, footer, `footer${f}`); continue }
for (let k = 0; k < tc; k++) {
const tab = tabs.nth(k)
if (!(await tab.isVisible()) || await tab.isDisabled()) continue
await tab.click(); await away()
await footerTools(story, footer, `footer${f} · ${(await tab.textContent()).trim()}`)
}
}
}
// ▲ panel (narrow widths): commands that do not fit open above the slots.
await goto('sw-footer--responsive')
const narrow = page.locator('.sw-footer-story-width .hm-sw-footer').nth(4)
const toggle = narrow.locator('.hm-sw-footer-more')
if (await toggle.count()) {
await toggle.click(); await page.waitForTimeout(150)
const panel = page.locator('.hm-sw-footer__more-panel, [class*="more-panel"]').filter({ has: page.locator('.hm-sw-footer-tool') }).first()
if (await panel.count()) await footerTools('sw-footer--responsive', panel, '▲ panel')
}
report.footer = footerResults
check(`Footer: command slots measured (${footerResults.length})`, footerResults.length >= 20, footerResults.length)
const footerLabels = [...new Set(footerResults.map(r => r.label))]
report.footerLabels = footerLabels
for (const r of footerResults) check(`Footer "${r.name}": icon · text · gap · 68 × 47 unchanged (Default/Hover/Default/Hover/Active)`, r.ok && r.default.box[2] === 68 && r.default.box[3] === 47, { moved: r.moved, box: r.default.box })
check('Footer: hover still colors the command (#FF5C00) and swaps to the orange icon where one is given', footerResults.every(r => r.hover.color === 'rgb(255, 92, 0)'), footerResults.filter(r => r.hover.color !== 'rgb(255, 92, 0)').map(r => r.name))
// Selected (pressed) geometry = Default geometry
await goto('sw-footer--full-footer')
const work = page.locator('.sw-footer-story-workspace .hm-sw-footer')
const poly = work.locator('.hm-sw-footer-tool', { hasText: 'POLYGON' })
await poly.scrollIntoViewIfNeeded(); await away(); const polyDefault = await snap(poly, FQ)
await poly.click(); await away(); const polyPressed = await snap(poly, FQ)
check('Footer: Selected (aria-pressed) POLYGON keeps the Default geometry, accent color', await poly.getAttribute('aria-pressed') === 'true' && Object.keys(delta(polyDefault, polyPressed)).length === 0 && polyPressed.color === 'rgb(255, 92, 0)', delta(polyDefault, polyPressed))
await poly.click(); await away()
// ---------- 4. Footer ellipsis + tooltip ----------
const labelInfo = loc => loc.evaluate(b => { const l = b.querySelector('.hm-sw-footer-tool__label'), c = getComputedStyle(l), r = b.getBoundingClientRect(); return { text: l.textContent, overflow: c.textOverflow, cut: l.scrollWidth > l.clientWidth, client: l.clientWidth, scroll: l.scrollWidth, size: c.fontSize, title: b.getAttribute('title'), w: r.width, h: r.height } })
const ell = {}
for (const name of ['POLYGON', 'WIPEOUT', 'ELLIPSE', 'LINE']) {
const b = work.locator('.hm-sw-footer-tool', { hasText: name }).first()
const rest = await labelInfo(b)
await b.scrollIntoViewIfNeeded(); await b.hover(); await page.waitForTimeout(120)
const hover = await labelInfo(b)
await away()
ell[name] = { rest, hover }
}
report.ellipsis = ell
check('Footer: label ends in an ellipsis (text-overflow: ellipsis), font stays 14px, slot 68 × 47', Object.values(ell).every(e => e.rest.overflow === 'ellipsis' && e.rest.size === '14px' && e.rest.w === 68 && e.rest.h === 47), ell)
for (const name of ['POLYGON', 'WIPEOUT']) {
const e = ell[name]
check(`Footer: ${name} — ${e.rest.cut ? 'cut → ellipsis, hover title = full name' : 'fits → shown whole'}`, e.rest.cut ? e.hover.title === name : e.hover.title === null || e.hover.title === name, e)
}
check('Footer: a command that fits (LINE) gets no automatic tooltip', !ell.LINE.rest.cut && ell.LINE.hover.title === null, ell.LINE)
check('Footer: POLYGON / WIPEOUT accessible names are the full command names', await page.getByRole('button', { name: 'POLYGON', exact: true }).count() >= 1 && await page.getByRole('button', { name: 'WIPEOUT', exact: true }).count() >= 1)
check('Footer: ariaLabel still wins (RECTA... = Rectangle)', await work.getByRole('button', { name: 'Rectangle', exact: true }).count() === 1)
// Keyboard focus on a cut command also exposes the full name
const wipe = work.locator('.hm-sw-footer-tool', { hasText: 'WIPEOUT' }).first()
await wipe.focus(); await page.waitForTimeout(60)
check('Footer: keyboard focus on WIPEOUT exposes the same title', ell.WIPEOUT.rest.cut ? await wipe.getAttribute('title') === 'WIPEOUT' : true, await wipe.getAttribute('title'))
// An explicit SWFooterTool.title is kept as is (no duplicate tooltip)
const explicit = await page.evaluate(() => [...document.querySelectorAll('.hm-sw-footer-tool[title]')].map(b => [b.textContent, b.title]))
report.explicitTitles = explicit
// click still selects
await page.keyboard.press('Enter'); await page.waitForTimeout(80)
check('Footer: Enter on WIPEOUT selects it (aria-pressed)', await wipe.getAttribute('aria-pressed') === 'true')
await page.keyboard.press('Enter'); await page.waitForTimeout(80)
await page.screenshot({ path: path.join(out, `footer-${engine}.png`), clip: { x: 0, y: 0, width: 1920, height: 1100 } })
check('No page errors', errors.length === 0, errors)
await writeFile(path.join(out, `hover-stability-${engine}.json`), JSON.stringify({ checks, report }, null, 2))
const failed = checks.filter(c => !c.ok).length
console.log(`${checks.length - failed}/${checks.length} PASS (${engine})`)
await browser.close(); server.close()
process.exitCode = failed ? 1 : 0