From 413486958c5f55a2becff0bdcd815b4dcffbee4e Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 25 Aug 2026 04:41:51 +0900 Subject: [PATCH] Upgrade RFS and Saebyeok showcases --- apps/site/design.md | 30 ++ apps/site/gate-report.md | 2 +- apps/site/package.json | 1 + apps/site/public/work/rfs-cfd/index.html | 116 ++++-- .../work/rfs-cfd/scripts/thermal-field.js | 116 +++++- apps/site/public/work/rfs-cfd/styles/main.css | 197 ++++++++- .../public/work/saebyeok-cheonggwa/index.html | 352 +++++++++++++++- .../work/saebyeok-cheonggwa/styles/main.css | 275 ++++++++++++- apps/site/tools/e2e-report.md | 2 +- apps/site/tools/recent-showcases-e2e.mjs | 375 ++++++++++++++++++ apps/site/tools/twentyoz-e2e-report.md | 2 +- apps/site/tools/verify-report.md | 29 +- apps/site/tools/verify.mjs | 1 + 13 files changed, 1410 insertions(+), 88 deletions(-) create mode 100644 apps/site/tools/recent-showcases-e2e.mjs diff --git a/apps/site/design.md b/apps/site/design.md index 9cf8ee6..659359f 100644 --- a/apps/site/design.md +++ b/apps/site/design.md @@ -1103,3 +1103,33 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고 - `npm run verify`: 위 트웬티온스 전용 게이트와 저장소 전체 L0~L6를 한 번에 실행한다. - 3D 번들 예산은 gzip 200KB 이하이며 현재 기준은 166,225B다. GLB는 697,076B이며 로컬 폰트·폴백 이미지를 포함한 예산은 전용 E2E에서 하드 실패한다. - 2026-08-25 국소 조정: 3D 화면 중심은 모바일 50%, 태블릿 74%, 소형 PC 64%, 와이드 PC 60%를 기준으로 하며, 중복 현장 상태는 보조기술에만 알리고 3D 모션 제어는 히어로 CTA 행에 둔다. + +## RFS CFD · 새벽청과 연장 업그레이드 (2026-08-25) + +### 유지한 방향 + +- 신규 콘셉트로 갈아엎지 않고 기존의 검증된 대비를 연장한다. RFS는 **암실과 사양서**, 새벽청과는 **오늘 새벽 두 시의 값**을 계속 기준으로 삼는다. +- RFS의 실제 시뮬레이션 렌더와 새벽청과의 실물 과일 사진을 유지했다. 생성 이미지로 제품 증거나 산지 품질을 꾸미면 실제 출력·실물처럼 오해될 수 있으므로 이 판에서는 이미지 생성 도구를 쓰지 않았다. +- 장식 면적을 늘리지 않고 사용자가 정보를 읽은 다음 내릴 수 있는 판단과 행동을 추가했다. + +### RFS — 조작에서 해석으로 + +- 히어로에 `기준 운전 · 랙 과부하 · 급기 저하` 프리셋을 두고 서버랙 부하와 FWU 급기를 실제 슬라이더 값으로 함께 바꾼다. +- 부하와 급기의 차이를 읽어 `현재 상태 · 먼저 볼 곳 · 다음 조작`을 한국어로 해석한다. 정확도나 온도 수치는 새로 만들지 않는다. +- 상면·측면 실물 렌더를 한 뷰어의 전환 상태로 합쳐 두 이미지가 세로로 쌓이며 생기던 공백을 없앴다. +- 감소 모션이나 WebGL 실패에서도 프리셋·해석·렌더 전환은 작동하고, 유동 화면만 정적으로 남는다. + +### 새벽청과 — 시세에서 발주 메모로 + +- 경매판의 여섯 품목을 선택 가능한 로트로 바꾸고 산지·등급·당도·규격·낙찰가·전일 대비를 하나의 판독기에 동기화한다. +- `오늘 경매`는 하드코딩 날짜가 아니라 한국 표준시의 실제 날짜로 표시한다. +- 제철 띠에 이전 달·다음 달 탐색과 해당 달의 품목 목록을 붙였다. +- 선택한 로트를 발주 메모로 이어 최소 3상자·최대 50상자 범위와 예시 낙찰가 합계를 계산한다. 선별·포장·배송·실제 납품가는 포함되지 않는다고 바로 아래에 적었다. +- 확인되지 않은 가상 이메일 주소는 제거했다. 복사는 클립보드 API가 응답하지 않으면 1.2초 뒤 선택 복사로 전환하며, 성공·실패를 상태 문장으로 알린다. + +### 검증 기준과 현재 증거 + +- `npm run test:e2e:recent-showcases`는 320/390/768/1440 반응형, 터치 타깃, 왼쪽 인셋, 이미지, RFS 프리셋·수동 조정·렌더 전환·감소 모션, 새벽청과 로트·제철·수량 경계·계산·실제 클립보드, 두 페이지 axe·JS 없음·런타임 오류를 검사한다. +- 최초 실행에서 CSS 토큰 경로 404와 응답하지 않는 클립보드 권한을 각각 재현했다. 토큰을 명시적 스타일 링크로 바꾸고 복사 폴백을 추가한 뒤 성능 계측을 포함해 **115/115 통과**했다. +- `showcase-audit.mjs rfs-cfd saebyeok-cheonggwa`는 두 페이지 × 320/390/768/1440, 총 **8/8 통과**했다. +- 위 전용 E2E는 저장소 전체 `verify.mjs`의 L6에 포함한다. 배포 전에는 전체 게이트를 다시 실행한다. diff --git a/apps/site/gate-report.md b/apps/site/gate-report.md index 7aaeac5..d2fd826 100644 --- a/apps/site/gate-report.md +++ b/apps/site/gate-report.md @@ -1,4 +1,4 @@ -# 게이트 리포트 — 2026-08-24 17:12 +# 게이트 리포트 — 2026-08-24 19:37 **전 항목 통과 — 배포 가능** diff --git a/apps/site/package.json b/apps/site/package.json index 3551f7f..022cc21 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -8,6 +8,7 @@ "build:showcase-assets": "node tools/build-showcase-assets.mjs", "test:design:gate": "node tools/design-gate.mjs --config=gate.config.json", "test:design:products": "node tools/design-gate.mjs --config=gate.twentyoz-products.config.json", + "test:e2e:recent-showcases": "node tools/recent-showcases-e2e.mjs", "test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs", "prebuild": "npm run build:showcase-assets", "build": "astro build", diff --git a/apps/site/public/work/rfs-cfd/index.html b/apps/site/public/work/rfs-cfd/index.html index ad9bd1b..339b065 100644 --- a/apps/site/public/work/rfs-cfd/index.html +++ b/apps/site/public/work/rfs-cfd/index.html @@ -27,7 +27,8 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" /> - + + +
+

운전 장면

+ + + +
+
@@ -118,6 +153,27 @@
+
+
+

브라우저 데모 해석

+

냉기와 발열이 맞서는 기준 상태입니다.

+
+
+
+
장면
+
기준 운전
+
+
+
먼저 볼 곳
+
랙 배면
+
+
+
다음 조작
+
부하와 급기를 따로 움직여 비교
+
+
+
+

위 화면은 브라우저에서 도는 2D 축소판이다. 슬라이더를 움직이거나 화면을 문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 — @@ -329,28 +385,42 @@

-
- RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다 -
상면 · 랙 상부의 열기둥
-
-
- RFS 측면 뷰 — 랙을 통과하며 데워진 공기가 오른쪽으로 밀려 나가는 단면 -
측면 · 랙을 통과한 공기
-
+
+
+ + +
+

RFS 실물 렌더

+
+
+
+ RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다 +
상면 · 랙 상부의 열기둥
+
+ +
+
+ 저온 + 중간 + 고온 +

두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 — 이 페이지 첫 화면의 색도 같은 규칙을 쓴다. diff --git a/apps/site/public/work/rfs-cfd/scripts/thermal-field.js b/apps/site/public/work/rfs-cfd/scripts/thermal-field.js index 6138126..9a11b1d 100644 --- a/apps/site/public/work/rfs-cfd/scripts/thermal-field.js +++ b/apps/site/public/work/rfs-cfd/scripts/thermal-field.js @@ -34,6 +34,101 @@ const shell = canvas.closest("[data-field-shell]"); const setState = (s) => shell && shell.setAttribute("data-field-state", s); + /* ── 데모 조작 UI ──────────────────────────────────────────────── + WebGL 이 없거나 모션을 줄여도 조작의 의미는 남아야 한다. + 그래서 슬라이더·장면·실물 뷰 전환은 그래픽 초기화보다 먼저 묶는다. */ + const controls = { + load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100, + supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100, + }; + + const presetButtons = [...document.querySelectorAll("[data-field-preset]")]; + const fieldState = document.getElementById("fieldState"); + const fieldScene = document.getElementById("fieldScene"); + const fieldWatch = document.getElementById("fieldWatch"); + const fieldAction = document.getElementById("fieldAction"); + + const activePreset = () => presetButtons.find((button) => button.getAttribute("aria-pressed") === "true"); + + function updateReadout() { + const gap = Math.round((controls.load - controls.supply) * 100); + const selected = activePreset(); + if (fieldScene) fieldScene.textContent = selected?.textContent.trim() || "수동 조정"; + + let state = "냉기와 발열이 맞서는 기준 상태입니다."; + let watch = "랙 배면"; + let action = "부하와 급기를 따로 움직여 비교"; + + if (gap >= 35) { + state = "발열이 급기보다 크게 앞섭니다. 배면에서 천장으로 오르는 열기둥을 확인하십시오."; + watch = "랙 배면 · 천장 배기"; + action = "급기 상향과 부하 재배분을 비교"; + } else if (gap >= 15) { + state = "발열 우세 구간입니다. 랙 뒤에 머무는 고온 입자의 경로를 비교하십시오."; + watch = "랙 배면"; + action = "급기량을 한 단계씩 높여 비교"; + } else if (gap <= -15) { + state = "급기가 발열을 앞섭니다. FWU 제트가 만드는 저온 구간을 확인하십시오."; + watch = "FWU 제트 · 냉점"; + action = "과냉 구간과 팬 운전을 함께 검토"; + } + + if (fieldState) fieldState.textContent = state; + if (fieldWatch) fieldWatch.textContent = watch; + if (fieldAction) fieldAction.textContent = action; + } + + function matchPreset() { + const load = Math.round(controls.load * 100); + const supply = Math.round(controls.supply * 100); + let matched = false; + for (const button of presetButtons) { + const same = Number(button.dataset.load) === load && Number(button.dataset.supply) === supply; + button.setAttribute("aria-pressed", String(same)); + matched ||= same; + } + if (!matched) presetButtons.forEach((button) => button.setAttribute("aria-pressed", "false")); + } + + function bindSlider(id, key, outId, fmt) { + const el = document.getElementById(id); + const out = document.getElementById(outId); + if (!el) return; + const sync = () => { + controls[key] = Number(el.value) / 100; + if (out) out.textContent = fmt(Number(el.value)); + matchPreset(); + updateReadout(); + }; + el.addEventListener("input", sync); + sync(); + } + + bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`); + bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`); + + for (const button of presetButtons) { + button.addEventListener("click", () => { + const load = document.getElementById("ctlLoad"); + const supply = document.getElementById("ctlSupply"); + if (!(load instanceof HTMLInputElement) || !(supply instanceof HTMLInputElement)) return; + load.value = button.dataset.load || load.value; + supply.value = button.dataset.supply || supply.value; + load.dispatchEvent(new Event("input", { bubbles: true })); + supply.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + const visTabs = [...document.querySelectorAll("[data-vis-target]")]; + const visPanels = [...document.querySelectorAll("[data-vis-panel]")]; + for (const tab of visTabs) { + tab.addEventListener("click", () => { + const target = tab.dataset.visTarget; + for (const button of visTabs) button.setAttribute("aria-pressed", String(button === tab)); + for (const panel of visPanels) panel.hidden = panel.dataset.visPanel !== target; + }); + } + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); if (reduced.matches) { setState("static"); @@ -450,27 +545,6 @@ } } - /* ── 조작 입력 ────────────────────────────────────────────────── */ - const controls = { - load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100, - supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100, - }; - - function bindSlider(id, key, outId, fmt) { - const el = document.getElementById(id); - const out = document.getElementById(outId); - if (!el) return; - const sync = () => { - controls[key] = Number(el.value) / 100; - if (out) out.textContent = fmt(Number(el.value)); - }; - el.addEventListener("input", sync); - sync(); - } - - bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`); - bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`); - const pointer = { x: 0, y: 0, on: 0 }; const onMove = (e) => { const r = canvas.getBoundingClientRect(); diff --git a/apps/site/public/work/rfs-cfd/styles/main.css b/apps/site/public/work/rfs-cfd/styles/main.css index a3431ce..c3f4eeb 100644 --- a/apps/site/public/work/rfs-cfd/styles/main.css +++ b/apps/site/public/work/rfs-cfd/styles/main.css @@ -1,5 +1,3 @@ -@import "./tokens.css"; - /* ========================================================================== RFS — 메인 스타일시트 @@ -361,8 +359,48 @@ h3 { color: var(--band-ink-muted); } -.hero-controls { +.scenario-strip { margin-top: var(--space-5); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + max-width: 760px; +} + +.scenario-label { + margin-right: var(--space-2); + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.scenario-btn, +.vis-tab { + min-height: 40px; + padding-inline: var(--space-3); + border: 1px solid var(--band-line-strong); + background: transparent; + color: var(--band-ink-muted); + cursor: pointer; + font-size: var(--step--1); +} + +.scenario-btn:hover, +.vis-tab:hover { + color: var(--band-ink); + border-color: var(--band-accent); +} + +.scenario-btn[aria-pressed="true"], +.vis-tab[aria-pressed="true"] { + color: var(--band-accent-ink); + background: var(--band-accent); + border-color: var(--band-accent); +} + +.hero-controls { + margin-top: var(--space-4); display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-4); @@ -389,6 +427,58 @@ input[type="range"] { background: transparent; } +.field-readout { + margin-top: var(--space-5); + max-width: 880px; + border-block: 1px solid var(--band-line-strong); +} + +.field-readout-head { + display: grid; + gap: var(--space-2); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +.field-readout-label, +.field-readout-grid dt { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.field-readout-state { + color: var(--band-ink); +} + +.field-readout-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-readout-grid > div { + min-width: 0; + padding-block: var(--space-3); +} + +.field-readout-grid > div + div { + padding-left: var(--space-3); + border-left: 1px solid var(--band-line); +} + +.field-readout-grid dd { + margin-top: var(--space-1); + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-normal); +} + +@media (min-width: 720px) { + .field-readout-head { + grid-template-columns: 9rem minmax(0, 1fr); + align-items: baseline; + } +} + .hero-note { margin-top: var(--space-5); padding-top: var(--space-3); @@ -692,17 +782,54 @@ input[type="range"] { .vis-shots { display: grid; - gap: var(--space-4); + gap: var(--space-3); + min-width: 0; +} + +.vis-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + border-bottom: 1px solid var(--band-line-strong); + padding-bottom: var(--space-2); +} + +.vis-tabs { + display: flex; +} + +.vis-tab + .vis-tab { + border-left: 0; +} + +.vis-source { + color: var(--band-ink-faint); + font-size: var(--step--1); + text-align: right; +} + +.vis-frame { + min-width: 0; } .vis-shots figure { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; border: 1px solid var(--band-line); background: var(--band-surface-2); + min-height: clamp(360px, 42vw, 540px); +} + +.vis-shots figure[hidden] { + display: none; } .vis-shots img { width: 100%; - height: auto; + height: 100%; + min-height: 0; + object-fit: contain; } .vis-shots figcaption { @@ -713,6 +840,35 @@ input[type="range"] { font-family: var(--font-mono); } +.temp-key { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + color: var(--band-ink-muted); + font-family: var(--font-mono); + font-size: var(--step--1); +} + +.temp-key span { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.temp-key i { + width: 1.5rem; + height: 2px; + background: var(--temp-cold); +} + +.temp-key .temp-mid { + background: var(--temp-mid); +} + +.temp-key .temp-hot { + background: var(--temp-hot); +} + .shots-note { font-size: var(--step--1); color: var(--band-ink-faint); @@ -979,6 +1135,35 @@ kbd { font-size: 0.6875rem; } + .scenario-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .scenario-label { + grid-column: 1 / -1; + } + + .scenario-btn, + .vis-tab { + min-height: 44px; + padding-inline: var(--space-2); + } + + .field-readout-grid { + grid-template-columns: 1fr; + } + + .field-readout-grid > div + div { + padding-left: 0; + border-left: 0; + border-top: 1px solid var(--band-line); + } + + .vis-shots figure { + min-height: 320px; + } + .figures { grid-template-columns: repeat(2, 1fr); } @@ -1014,6 +1199,8 @@ kbd { .site-head, .hero-stage, .hero-controls, + .scenario-strip, + .field-readout, .skip-link { display: none; } diff --git a/apps/site/public/work/saebyeok-cheonggwa/index.html b/apps/site/public/work/saebyeok-cheonggwa/index.html index dbd6270..b747d46 100644 --- a/apps/site/public/work/saebyeok-cheonggwa/index.html +++ b/apps/site/public/work/saebyeok-cheonggwa/index.html @@ -24,7 +24,8 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" /> - + + - 백도 복숭아 + + + 경북 청도 특 12.5Bx @@ -106,7 +121,21 @@ ▲ 2,000 - 샤인머스캣 + + + 경북 상주 특 18.2Bx @@ -115,7 +144,21 @@ ▼ 1,500 - 캠벨얼리 + + + 충북 영동 상 15.4Bx @@ -124,7 +167,21 @@ 보합 - 홍로 사과 + + + 경북 영주 특 14.1Bx @@ -133,7 +190,21 @@ ▲ 3,500 - 원황 배 + + + 전남 나주 상 12.8Bx @@ -142,7 +213,21 @@ ▼ 2,000 - 무화과 + + + 전남 영암 특 — @@ -153,6 +238,37 @@ +

+
+

선택 로트 읽기

+

02:00 경매

+
+
+
+

백도 복숭아

+

경북 청도

+
+
+
+
등급 · 당도
+
특 · 12.5 Bx
+
+
+
규격
+
4.5 kg
+
+
+
낙찰가
+
38,000원
+
+
+
전일 대비
+
▲ 2,000원
+
+
+
+
+
  • 사과와 배는 같은 날 08:30 경매입니다. 나머지 넷은 @@ -276,6 +392,18 @@ 판에 오릅니다.

    +
    +
    + + +
    +

    + 8월 + + 제철 품목을 읽는 중입니다 +

    +
    +
    @@ -507,9 +635,47 @@
    품목당 상자 3 개부터
    - - 납품 조건 문의 - + +
    +
    +

    발주 메모 만들기

    +

    쇼케이스 예시

    +
    +
    + + +
    +
    +
    +
    규격
    +
    4.5 kg × 3상자
    +
    +
    +
    예시 낙찰가 합계
    +
    114,000원
    +
    +
    +

    + 위 합계는 판에 적힌 예시 낙찰가만 더합니다. 선별·포장·배송과 실제 납품가는 포함하지 않습니다. +

    +
    + +
    +

    +
    @@ -559,14 +725,168 @@ diff --git a/apps/site/public/work/saebyeok-cheonggwa/styles/main.css b/apps/site/public/work/saebyeok-cheonggwa/styles/main.css index 30e9043..96f05a3 100644 --- a/apps/site/public/work/saebyeok-cheonggwa/styles/main.css +++ b/apps/site/public/work/saebyeok-cheonggwa/styles/main.css @@ -1,5 +1,3 @@ -@import "./tokens.css"; - /* ========================================================================== 새벽청과 — 메인 스타일시트 @@ -42,6 +40,14 @@ img { max-width: 100%; } +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + a { color: inherit; } @@ -305,6 +311,25 @@ h3 { white-space: nowrap; } +.board-select { + min-height: 40px; + border: 0; + background: transparent; + color: inherit; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.board tbody tr:has(.board-select[aria-pressed="true"]) { + background: color-mix(in srgb, var(--band-accent-wash) 72%, transparent); + box-shadow: inset 2px 0 var(--band-accent); +} + +.board-select[aria-pressed="true"] { + color: var(--band-accent); +} + .board td { color: var(--band-ink-muted); } @@ -340,6 +365,67 @@ h3 { color: var(--band-ink-faint); } +.lot-inspector { + margin-top: var(--space-4); + border-block: 1px solid var(--band-line-strong); +} + +.lot-inspector-head { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding-block: var(--space-2); + border-bottom: 1px solid var(--band-line); + color: var(--band-ink-faint); + font-size: var(--step--1); +} + +.lot-inspector-head p:last-child { + color: var(--band-accent); +} + +.lot-inspector-body { + display: grid; + grid-template-columns: minmax(13rem, 1.4fr) minmax(0, 3fr); + gap: var(--space-5); + padding-block: var(--space-4); +} + +.lot-title p:first-child { + font-size: var(--step-2); + font-weight: 600; +} + +.lot-title p:last-child { + margin-top: var(--space-1); + color: var(--band-ink-muted); + font-size: var(--step--1); +} + +.lot-spec { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.lot-spec > div { + min-width: 0; + padding-left: var(--space-3); + border-left: 1px solid var(--band-line); +} + +.lot-spec dt { + color: var(--band-ink-faint); + font-size: var(--step--1); +} + +.lot-spec dd { + margin-top: var(--space-2); + color: var(--band-ink); + font-family: var(--font-mono); + font-size: var(--step--1); + font-variant-numeric: tabular-nums; +} + .board-notes { margin-top: var(--space-5); list-style: none; @@ -497,10 +583,53 @@ h3 { /* ── 제철 띠 ────────────────────────────────────────────────────── */ .season-scroll { - margin-top: var(--space-6); + margin-top: var(--space-3); overflow-x: auto; } +.season-control { + margin-top: var(--space-5); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--band-line-strong); +} + +.season-buttons { + display: flex; +} + +.season-buttons button { + min-height: 40px; + padding-inline: var(--space-3); + border: 1px solid var(--band-line-strong); + background: transparent; + color: var(--band-ink-muted); + cursor: pointer; + font-size: var(--step--1); +} + +.season-buttons button + button { + border-left: 0; +} + +.season-buttons button:hover { + color: var(--band-ink); + border-color: var(--band-accent); +} + +.season-status { + color: var(--band-ink-muted); + font-size: var(--step--1); + text-align: right; +} + +.season-status output { + color: var(--band-accent); +} + .season { width: 100%; min-width: 34rem; @@ -541,11 +670,11 @@ h3 { /* 이번 달 — 배경 대신 테두리로 표시한다. 칸이 찬 것과 이번 달이 겹쳐도 둘 다 읽혀야 하기 때문이다 */ -.season .is-now { +.season .is-focus { border-inline: 2px solid var(--band-accent); } -.season thead .is-now { +.season thead .is-focus { color: var(--band-accent); border-top: 2px solid var(--band-accent); } @@ -677,6 +806,99 @@ h3 { font-weight: 600; } +.order-draft { + margin-top: var(--space-6); + padding-block: var(--space-4); + border-block: 1px solid var(--band-line-strong); +} + +.order-draft-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); +} + +.order-draft-head h3 { + font-size: var(--step-2); +} + +.order-draft-head p { + color: var(--band-ink-faint); + font-size: var(--step--1); +} + +.order-controls { + margin-top: var(--space-4); + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(8rem, 1fr); + gap: var(--space-3); +} + +.order-controls label { + display: grid; + gap: var(--space-2); + color: var(--band-ink-faint); + font-size: var(--step--1); +} + +.order-controls select, +.order-controls input { + min-width: 0; + min-height: 44px; + padding-inline: var(--space-3); + border: 1px solid var(--band-line-strong); + background: var(--band-surface-2); + color: var(--band-ink); +} + +.order-summary { + margin-top: var(--space-4); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--band-line); +} + +.order-summary > div { + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +.order-summary > div + div { + padding-left: var(--space-3); + border-left: 1px solid var(--band-line); +} + +.order-summary dt, +.order-fine, +.order-status { + color: var(--band-ink-faint); + font-size: var(--step--1); +} + +.order-summary dd { + margin-top: var(--space-1); + color: var(--band-ink); +} + +.order-fine { + margin-top: var(--space-3); + line-height: var(--leading-loose); +} + +.order-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-4); +} + +.order-status { + min-height: 1.7em; + margin-top: var(--space-3); + color: var(--band-ink-muted); +} + .contact-fine { margin-top: var(--space-4); font-size: var(--step--1); @@ -687,14 +909,16 @@ h3 { .btn { display: inline-block; + min-height: 44px; margin-top: var(--space-5); padding: var(--space-3) var(--space-5); + border: 0; background: var(--band-accent); color: var(--band-accent-ink); font-weight: 600; text-decoration: none; white-space: nowrap; - transition: background-color var(--dur-quick) var(--ease); + cursor: pointer; } .btn:hover { @@ -772,6 +996,10 @@ h3 { border: 0; } + .board-select { + min-height: 44px; + } + .board td { padding: var(--space-1) 0; border: 0; @@ -787,6 +1015,37 @@ h3 { font-family: var(--font-body); } + .lot-inspector-body, + .lot-spec, + .order-controls, + .order-summary { + grid-template-columns: 1fr; + } + + .lot-spec > div { + padding: var(--space-2) 0; + border-left: 0; + border-top: 1px solid var(--band-line); + } + + .season-control { + align-items: flex-start; + flex-direction: column; + } + + .season-buttons button { + min-height: 44px; + } + + .season-status { + text-align: left; + } + + .order-summary > div + div { + padding-left: 0; + border-left: 0; + } + .today-card-wide { display: block; } @@ -812,6 +1071,10 @@ h3 { .site-head, .skip-link, .btn, + .season-control, + .order-controls, + .order-actions, + .order-status, .hero-bg { display: none; } diff --git a/apps/site/tools/e2e-report.md b/apps/site/tools/e2e-report.md index 7345786..a20e30d 100644 --- a/apps/site/tools/e2e-report.md +++ b/apps/site/tools/e2e-report.md @@ -1,4 +1,4 @@ -# E2E 시나리오 게이트 — 2026-08-24 17:13 +# E2E 시나리오 게이트 — 2026-08-24 19:39 단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과** diff --git a/apps/site/tools/recent-showcases-e2e.mjs b/apps/site/tools/recent-showcases-e2e.mjs new file mode 100644 index 0000000..6f0e70a --- /dev/null +++ b/apps/site/tools/recent-showcases-e2e.mjs @@ -0,0 +1,375 @@ +// RFS CFD + 새벽청과 L6 — upgraded showcase journeys in real Chromium. +import puppeteer from "puppeteer-core"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import url from "node:url"; + +const require = createRequire(import.meta.url); +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const publicRoot = path.join(root, "public"); +const chrome = [ + process.env.CHROME_PATH, + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", +].find((candidate) => candidate && fs.existsSync(candidate)); +if (!chrome) throw new Error("Chrome executable not found"); + +const MIME = new Map([ + [".html", "text/html; charset=utf-8"], + [".css", "text/css; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".svg", "image/svg+xml"], + [".webp", "image/webp"], + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".woff2", "font/woff2"], +]); + +const server = http.createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const candidate = path.resolve(publicRoot, `.${pathname}`); + if (!candidate.startsWith(publicRoot + path.sep)) { + response.writeHead(403).end("forbidden"); + return; + } + const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() + ? path.join(candidate, "index.html") + : candidate; + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + response.writeHead(404).end("not found"); + return; + } + response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream"); + response.setHeader("Cache-Control", "no-store"); + fs.createReadStream(file).pipe(response); +}); + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +const origin = `http://127.0.0.1:${address.port}`; +const urls = { + rfs: `${origin}/work/rfs-cfd/`, + market: `${origin}/work/saebyeok-cheonggwa/`, +}; + +let pass = 0; +let fail = 0; +const suites = new Map(); +const failures = []; +const browserErrors = []; +const assert = (condition, suite, name, detail = "") => { + const state = suites.get(suite) || { pass: 0, fail: 0 }; + if (condition) { + pass += 1; + state.pass += 1; + } else { + fail += 1; + state.fail += 1; + failures.push(`${suite} · ${name}${detail ? ` — ${detail}` : ""}`); + } + suites.set(suite, state); +}; +const assertEvery = (items, predicate, suite, name, describe = (item) => JSON.stringify(item)) => { + for (const item of items) assert(predicate(item), suite, name, describe(item)); +}; + +const browser = await puppeteer.launch({ + executablePath: chrome, + headless: "new", + args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"], +}); + +async function newPage({ javascript = true } = {}) { + const page = await browser.newPage(); + await page.setCacheEnabled(false); + await page.setJavaScriptEnabled(javascript); + if (javascript) { + await page.evaluateOnNewDocument(() => { + window.__recentShowcasePerf = { cls: 0, lcp: 0 }; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) window.__recentShowcasePerf.cls += entry.value; + } + }).observe({ type: "layout-shift", buffered: true }); + new PerformanceObserver((list) => { + const entries = list.getEntries(); + if (entries.length) window.__recentShowcasePerf.lcp = entries.at(-1).startTime; + }).observe({ type: "largest-contentful-paint", buffered: true }); + }); + } + page.on("pageerror", (error) => browserErrors.push(`pageerror: ${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") browserErrors.push(`console: ${message.text()}`); + }); + page.on("response", (response) => { + if (response.status() >= 400) browserErrors.push(`response: ${response.status()} ${response.url()}`); + }); + return page; +} + +async function goto(page, target) { + await page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForNetworkIdle({ idleTime: 250, timeout: 5000 }).catch(() => {}); + await Promise.race([ + page.evaluate(() => document.fonts.ready), + new Promise((resolve) => setTimeout(resolve, 5000)), + ]); +} + +function expectedKoreanStamp() { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Seoul", + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .formatToParts(new Date()) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, part.value]), + ); + return { + month: Number(parts.month), + text: `${parts.month}월 ${parts.day}일 02:00`, + datetime: `${parts.year}-${parts.month}-${parts.day}T02:00+09:00`, + }; +} + +try { + const page = await newPage(); + + // 1. Both pages remain structurally complete throughout the target width matrix. + for (const [site, target] of Object.entries(urls)) { + for (const width of [320, 390, 768, 1440]) { + await page.setViewport({ width, height: width < 500 ? 844 : 900, isMobile: width < 500, hasTouch: width < 500 }); + await goto(page, target); + if (site === "rfs") { + await page.waitForFunction( + () => document.querySelector("[data-field-shell]")?.dataset.fieldState !== "loading", + { timeout: 8000 }, + ); + } + const geometry = await page.evaluate((siteName) => { + const h1 = document.querySelector("h1"); + const lead = document.querySelector(".hero-lead, .hero-intro"); + const targetSelector = siteName === "rfs" + ? ".scenario-btn, .vis-tab" + : ".board-select, [data-season-dir], #copyOrder, #orderItem, #orderQty"; + const targets = [...document.querySelectorAll(targetSelector)] + .filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { label: element.textContent.trim().slice(0, 24), width: Math.round(rect.width), height: Math.round(rect.height) }; + }); + return { + width: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + h1Count: document.querySelectorAll("h1").length, + h1Inset: Math.round(h1.getBoundingClientRect().left), + leadInset: lead ? Math.round(lead.getBoundingClientRect().left) : null, + targets, + brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).length, + fieldState: document.querySelector("[data-field-shell]")?.dataset.fieldState || "n/a", + }; + }, site); + const suite = `${site} responsive ${width}`; + assert(geometry.scrollWidth <= geometry.width, suite, "no horizontal overflow", `${geometry.scrollWidth}/${geometry.width}`); + assert(geometry.h1Count === 1, suite, "one h1", String(geometry.h1Count)); + assert(geometry.h1Inset >= 12, suite, "h1 left inset >=12px", String(geometry.h1Inset)); + assert(geometry.leadInset === null || Math.abs(geometry.h1Inset - geometry.leadInset) <= 10, suite, "hero copy shares one left axis", `${geometry.h1Inset}/${geometry.leadInset}`); + assert(geometry.brokenImages === 0, suite, "all images decode", String(geometry.brokenImages)); + if (width < 500) assertEvery(geometry.targets, (item) => item.height >= 44, suite, "new touch target height >=44px", (item) => `${item.label} ${item.width}x${item.height}`); + if (site === "rfs") assert(["live", "static"].includes(geometry.fieldState), suite, "field exits loading", geometry.fieldState); + } + } + + // 2. RFS presets interpret the operating state, manual edits clear the preset, and render views switch. + await page.setViewport({ width: 1440, height: 900 }); + await goto(page, urls.rfs); + await page.waitForFunction(() => document.querySelector("[data-field-shell]")?.dataset.fieldState !== "loading", { timeout: 8000 }); + assert((await page.$$eval("[data-field-preset]", (items) => items.length)) === 3, "RFS controls", "three operating presets"); + await page.click('[data-field-preset="overload"]'); + const overload = await page.evaluate(() => ({ + load: document.querySelector("#ctlLoad").value, + supply: document.querySelector("#ctlSupply").value, + selected: document.querySelector('[data-field-preset="overload"]').getAttribute("aria-pressed"), + scene: document.querySelector("#fieldScene").textContent.trim(), + state: document.querySelector("#fieldState").textContent.trim(), + watch: document.querySelector("#fieldWatch").textContent.trim(), + action: document.querySelector("#fieldAction").textContent.trim(), + })); + assert(overload.load === "95" && overload.supply === "50", "RFS controls", "overload preset sets both inputs", `${overload.load}/${overload.supply}`); + assert(overload.selected === "true" && overload.scene === "랙 과부하", "RFS controls", "selected preset is announced", `${overload.selected}/${overload.scene}`); + assert(overload.state.includes("발열이 급기보다 크게"), "RFS controls", "output interprets the imbalance", overload.state); + assert(overload.watch.includes("천장 배기") && overload.action.includes("급기 상향"), "RFS controls", "output gives observation and next action", `${overload.watch}/${overload.action}`); + + await page.$eval("#ctlLoad", (input) => { + input.value = "80"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + const manual = await page.evaluate(() => ({ + scene: document.querySelector("#fieldScene").textContent.trim(), + pressed: [...document.querySelectorAll("[data-field-preset]")].map((button) => button.getAttribute("aria-pressed")), + out: document.querySelector("#outLoad").textContent.trim(), + })); + assert(manual.scene === "수동 조정", "RFS controls", "manual slider clears named scene", manual.scene); + assert(manual.pressed.every((value) => value === "false"), "RFS controls", "manual slider clears every preset", manual.pressed.join(",")); + assert(manual.out === "80%", "RFS controls", "manual slider output stays synchronized", manual.out); + + await page.click('[data-vis-target="side"]'); + const view = await page.evaluate(() => ({ + topHidden: document.querySelector('[data-vis-panel="top"]').hidden, + sideHidden: document.querySelector('[data-vis-panel="side"]').hidden, + sidePressed: document.querySelector('[data-vis-target="side"]').getAttribute("aria-pressed"), + visibleFigures: [...document.querySelectorAll("[data-vis-panel]")].filter((figure) => !figure.hidden).length, + })); + assert(view.topHidden && !view.sideHidden && view.sidePressed === "true", "RFS visual output", "side view replaces top view", JSON.stringify(view)); + assert(view.visibleFigures === 1, "RFS visual output", "only one render occupies the viewer", String(view.visibleFigures)); + + await page.focus('[data-field-preset="baseline"]'); + await page.keyboard.press("Tab"); + assert(await page.$eval('[data-field-preset="overload"]', (button) => button === document.activeElement), "RFS keyboard", "preset order follows visual order"); + await page.keyboard.press("Enter"); + assert(await page.$eval('[data-field-preset="overload"]', (button) => button.getAttribute("aria-pressed") === "true"), "RFS keyboard", "preset activates from keyboard"); + + // 3. Reduced-motion keeps RFS interpretation interactive without starting the simulation. + await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); + await goto(page, urls.rfs); + await page.click('[data-field-preset="supply-loss"]'); + const reduced = await page.evaluate(() => ({ + fieldState: document.querySelector("[data-field-shell]").dataset.fieldState, + scene: document.querySelector("#fieldScene").textContent.trim(), + load: document.querySelector("#ctlLoad").value, + supply: document.querySelector("#ctlSupply").value, + })); + assert(reduced.fieldState === "static", "RFS reduced motion", "simulation is static", reduced.fieldState); + assert(reduced.scene === "급기 저하" && reduced.load === "65" && reduced.supply === "25", "RFS reduced motion", "controls and interpretation remain available", JSON.stringify(reduced)); + await page.emulateMediaFeatures([]); + + // 4. 새벽청과 uses the current Korean market date and turns every price row into a readable lot. + await goto(page, urls.market); + const stamp = expectedKoreanStamp(); + const date = await page.$eval(".hero-stamp time", (time) => ({ text: time.textContent.trim(), datetime: time.dateTime })); + assert(date.text === stamp.text && date.datetime === stamp.datetime, "새벽청과 date", "market stamp is current in Korea", `${date.text}/${date.datetime}`); + assert((await page.$$eval("[data-market-row]", (items) => items.length)) === 6, "새벽청과 lot board", "six selectable lots"); + await page.click('[data-market-row][data-name="무화과"]'); + const fig = await page.evaluate(() => ({ + selected: document.querySelector('[data-market-row][data-name="무화과"]').getAttribute("aria-pressed"), + name: document.querySelector("#lotName").textContent.trim(), + origin: document.querySelector("#lotOrigin").textContent.trim(), + grade: document.querySelector("#lotGrade").textContent.trim(), + pack: document.querySelector("#lotPack").textContent.trim(), + price: document.querySelector("#lotPrice").textContent.trim(), + orderItem: document.querySelector("#orderItem").value, + })); + assert(fig.selected === "true" && fig.name === "무화과" && fig.origin === "전남 영암", "새벽청과 lot board", "selected lot identity is synchronized", JSON.stringify(fig)); + assert(fig.grade.includes("당도 표기 대상 아님") && fig.pack === "1.2 kg" && fig.price === "24,000원", "새벽청과 lot board", "selected lot facts are explicit", JSON.stringify(fig)); + assert(fig.orderItem === "무화과", "새벽청과 lot board", "selected lot carries into order memo", fig.orderItem); + + // 5. Season browsing and order memo calculations have explicit, asserted outcomes. + const beforeMonth = await page.$eval("#seasonMonth", (output) => Number.parseInt(output.textContent, 10)); + await page.click('[data-season-dir="1"]'); + const season = await page.evaluate(() => ({ + month: Number.parseInt(document.querySelector("#seasonMonth").textContent, 10), + count: document.querySelector("#seasonCount").textContent.trim(), + selectedHeads: document.querySelectorAll(".season thead .is-focus").length, + })); + const expectedNextMonth = (beforeMonth % 12) + 1; + assert(season.month === expectedNextMonth, "새벽청과 season", "next month wraps correctly", `${beforeMonth}->${season.month}`); + assert(/^\d+품목 · /.test(season.count) && season.selectedHeads === 1, "새벽청과 season", "month has one highlighted header and a named list", JSON.stringify(season)); + + await page.select("#orderItem", "샤인머스캣"); + await page.$eval("#orderQty", (input) => { + input.value = "4"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + const order = await page.evaluate(() => ({ + pack: document.querySelector("#orderPack").textContent.trim(), + total: document.querySelector("#orderTotal").textContent.trim(), + quantity: document.querySelector("#orderQty").value, + })); + assert(order.pack === "2 kg × 4상자" && order.total === "106,000원", "새벽청과 order", "pack and auction example total recalculate", JSON.stringify(order)); + assert(order.quantity === "4", "새벽청과 order", "valid quantity is preserved", order.quantity); + + await page.$eval("#orderQty", (input) => { + input.value = "1"; + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + assert((await page.$eval("#orderQty", (input) => input.value)) === "3", "새벽청과 order", "quantity clamps to the stated minimum"); + + await browser.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write"]); + await page.$eval("#orderQty", (input) => { + input.value = "4"; + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + await page.click("#copyOrder"); + await page.waitForFunction(() => document.querySelector("#orderStatus")?.textContent.includes("복사했습니다")); + const copied = await page.evaluate(async () => ({ + status: document.querySelector("#orderStatus").textContent.trim(), + text: await navigator.clipboard.readText(), + })); + assert(copied.status === "발주 메모를 복사했습니다.", "새벽청과 order", "clipboard action reports success", copied.status); + assert(copied.text.includes("품목: 샤인머스캣") && copied.text.includes("수량: 4상자") && copied.text.includes("106,000원"), "새벽청과 order", "clipboard contains the actual selection", copied.text.replaceAll("\n", " / ")); + + // 6. Axe scans both upgraded surfaces, and content survives JavaScript loss. + const axePath = require.resolve("axe-core/axe.min.js"); + for (const [site, target] of Object.entries(urls)) { + await goto(page, target); + await page.addScriptTag({ path: axePath }); + const axe = await page.evaluate(async () => axe.run(document, { resultTypes: ["violations"] })); + const detail = axe.violations.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`).join("; "); + assert(axe.violations.length === 0, `${site} axe`, "zero WCAG violations", detail); + const perf = await page.evaluate(() => ({ + cls: window.__recentShowcasePerf?.cls ?? -1, + lcp: window.__recentShowcasePerf?.lcp ?? -1, + sameOriginBytes: performance.getEntriesByType("resource") + .filter((entry) => new URL(entry.name).origin === location.origin) + .reduce((sum, entry) => sum + (entry.encodedBodySize || entry.transferSize || 0), 0), + })); + assert(perf.cls >= 0 && perf.cls <= 0.1, `${site} performance`, "CLS <=0.1", String(perf.cls)); + assert(perf.lcp > 0 && perf.lcp <= 3500, `${site} performance`, "local LCP <=3.5s", String(perf.lcp)); + assert(perf.sameOriginBytes > 0 && perf.sameOriginBytes <= 2_000_000, `${site} performance`, "same-origin first load <=2MB", String(perf.sameOriginBytes)); + } + + for (const [site, target] of Object.entries(urls)) { + const noJs = await newPage({ javascript: false }); + await noJs.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await goto(noJs, target); + const fallback = await noJs.evaluate((siteName) => ({ + h1: document.querySelector("h1")?.textContent.trim() || "", + bodyLength: document.body.innerText.trim().length, + coreContent: siteName === "rfs" + ? document.querySelector("#problem")?.textContent.trim().length || 0 + : document.querySelectorAll("[data-market-row]").length, + overflow: document.documentElement.scrollWidth > innerWidth, + }), site); + assert(fallback.h1.length > 0 && fallback.bodyLength > 1000, `${site} no-JS`, "primary content remains readable", `${fallback.h1}/${fallback.bodyLength}`); + assert(fallback.coreContent > 0 && !fallback.overflow, `${site} no-JS`, "core evidence survives without horizontal overflow", JSON.stringify(fallback)); + await noJs.close(); + } + + assert(browserErrors.length === 0, "browser runtime", "zero page, console, and response errors", browserErrors.join(" | ")); + await page.close(); +} finally { + await browser.close(); + await new Promise((resolve) => server.close(resolve)); +} + +for (const [suite, result] of suites) { + console.log(`${result.fail ? "FAIL" : "PASS"} ${suite} — ${result.pass}/${result.pass + result.fail}`); +} +if (failures.length) { + console.error("\nFailures:"); + for (const failure of failures) console.error(`- ${failure}`); +} +console.log(`\nPASS ${pass} / FAIL ${fail}`); +process.exit(fail ? 1 : 0); diff --git a/apps/site/tools/twentyoz-e2e-report.md b/apps/site/tools/twentyoz-e2e-report.md index 1ebdfe1..0944268 100644 --- a/apps/site/tools/twentyoz-e2e-report.md +++ b/apps/site/tools/twentyoz-e2e-report.md @@ -1,4 +1,4 @@ -# TWENTYOZ E2E — 2026-08-24 17:13 +# TWENTYOZ E2E — 2026-08-24 19:39 **148 assertions passed · GREEN** diff --git a/apps/site/tools/verify-report.md b/apps/site/tools/verify-report.md index 436e4fe..10e3716 100644 --- a/apps/site/tools/verify-report.md +++ b/apps/site/tools/verify-report.md @@ -1,20 +1,21 @@ -# 검증 리포트 — 2026-08-24 17:13 +# 검증 리포트 — 2026-08-24 19:39 -게이트: **전 계층 통과 — 배포 가능** · 총 165.5초 +게이트: **전 계층 통과 — 배포 가능** · 총 166.6초 | 계층 | 결과 | 항목 | 소요 | |---|---|---|---| | L0-자산/트웬티온스 3D | PASS | PASS — | 0.2s | -| L0-정적/stylelint | PASS | PASS — | 5.2s | -| L0-정식/html-validate | PASS | PASS — | 2.9s | -| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.3s | +| L0-정적/stylelint | PASS | PASS — | 1.7s | +| L0-정식/html-validate | PASS | PASS — | 1.5s | +| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.2s | | L1-단위/계산로직 | PASS | PASS 40 | 0.1s | -| L2-기능/감사 | PASS | PASS 72 | 10.9s | -| L2-불변식/렌더 | PASS | PASS 10 | 16.9s | -| L2-디자인 게이트/메인 | PASS | PASS 130 | 33.0s | -| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.0s | -| L3-시각회귀/diff | PASS | PASS 30 | 12.0s | -| L4-WebKit | PASS | PASS 8 | 4.5s | -| L5-탐색/키보드 | PASS | PASS 14 | 6.4s | -| L6-시나리오/E2E | PASS | PASS — | 43.2s | -| L6-시나리오/트웬티온스 | PASS | PASS 148 | 23.0s | +| L2-기능/감사 | PASS | PASS 72 | 9.8s | +| L2-불변식/렌더 | PASS | PASS 10 | 17.2s | +| L2-디자인 게이트/메인 | PASS | PASS 130 | 32.1s | +| L2-디자인 게이트/제품 | PASS | PASS 15 | 6.8s | +| L3-시각회귀/diff | PASS | PASS 30 | 11.3s | +| L4-WebKit | PASS | PASS 8 | 3.5s | +| L5-탐색/키보드 | PASS | PASS 14 | 6.1s | +| L6-시나리오/E2E | PASS | PASS — | 42.6s | +| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 10.8s | +| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.7s | diff --git a/apps/site/tools/verify.mjs b/apps/site/tools/verify.mjs index 8453f8d..006c363 100644 --- a/apps/site/tools/verify.mjs +++ b/apps/site/tools/verify.mjs @@ -38,6 +38,7 @@ const steps = [ { layer: "L4-WebKit", cmd: () => spawnSync(process.execPath, ["tools/webkit.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L5-탐색/키보드", cmd: () => spawnSync(process.execPath, ["tools/exploratory.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) }, + { layer: "L6-시나리오/최근 쇼케이스", cmd: () => spawnSync(process.execPath, ["tools/recent-showcases-e2e.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) }, ];
    품목별 제철 달력 — 가로축은 1월부터 12월