This commit is contained in:
parent
9529e3747c
commit
413486958c
13 changed files with 1410 additions and 88 deletions
|
|
@ -1103,3 +1103,33 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고
|
||||||
- `npm run verify`: 위 트웬티온스 전용 게이트와 저장소 전체 L0~L6를 한 번에 실행한다.
|
- `npm run verify`: 위 트웬티온스 전용 게이트와 저장소 전체 L0~L6를 한 번에 실행한다.
|
||||||
- 3D 번들 예산은 gzip 200KB 이하이며 현재 기준은 166,225B다. GLB는 697,076B이며 로컬 폰트·폴백 이미지를 포함한 예산은 전용 E2E에서 하드 실패한다.
|
- 3D 번들 예산은 gzip 200KB 이하이며 현재 기준은 166,225B다. GLB는 697,076B이며 로컬 폰트·폴백 이미지를 포함한 예산은 전용 E2E에서 하드 실패한다.
|
||||||
- 2026-08-25 국소 조정: 3D 화면 중심은 모바일 50%, 태블릿 74%, 소형 PC 64%, 와이드 PC 60%를 기준으로 하며, 중복 현장 상태는 보조기술에만 알리고 3D 모션 제어는 히어로 CTA 행에 둔다.
|
- 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에 포함한다. 배포 전에는 전체 게이트를 다시 실행한다.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 게이트 리포트 — 2026-08-24 17:12
|
# 게이트 리포트 — 2026-08-24 19:37
|
||||||
|
|
||||||
**전 항목 통과 — 배포 가능**
|
**전 항목 통과 — 배포 가능**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"build:showcase-assets": "node tools/build-showcase-assets.mjs",
|
"build:showcase-assets": "node tools/build-showcase-assets.mjs",
|
||||||
"test:design:gate": "node tools/design-gate.mjs --config=gate.config.json",
|
"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: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",
|
"test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs",
|
||||||
"prebuild": "npm run build:showcase-assets",
|
"prebuild": "npm run build:showcase-assets",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="./styles/main.css" />
|
<link rel="stylesheet" href="./styles/tokens.css?v=2" />
|
||||||
|
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E1114'/%3E%3Cpath d='M4 21c4-6 8 4 12-2s8 3 12-3' stroke='%233E8FB0' stroke-width='2.4' fill='none' stroke-linecap='round'/%3E%3Cpath d='M4 27c4-6 8 4 12-2s8 3 12-3' stroke='%238CC152' stroke-width='2.4' fill='none' stroke-linecap='round' opacity='.75'/%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E1114'/%3E%3Cpath d='M4 21c4-6 8 4 12-2s8 3 12-3' stroke='%233E8FB0' stroke-width='2.4' fill='none' stroke-linecap='round'/%3E%3Cpath d='M4 27c4-6 8 4 12-2s8 3 12-3' stroke='%238CC152' stroke-width='2.4' fill='none' stroke-linecap='round' opacity='.75'/%3E%3C/svg%3E"
|
||||||
|
|
@ -101,6 +102,40 @@
|
||||||
시뮬레이터.
|
시뮬레이터.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="scenario-strip" role="group" aria-label="브라우저 데모 운전 장면">
|
||||||
|
<p class="scenario-label">운전 장면</p>
|
||||||
|
<button
|
||||||
|
class="scenario-btn"
|
||||||
|
type="button"
|
||||||
|
data-field-preset="baseline"
|
||||||
|
data-load="70"
|
||||||
|
data-supply="65"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
|
기준 운전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="scenario-btn"
|
||||||
|
type="button"
|
||||||
|
data-field-preset="overload"
|
||||||
|
data-load="95"
|
||||||
|
data-supply="50"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
랙 과부하
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="scenario-btn"
|
||||||
|
type="button"
|
||||||
|
data-field-preset="supply-loss"
|
||||||
|
data-load="65"
|
||||||
|
data-supply="25"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
급기 저하
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hero-controls">
|
<div class="hero-controls">
|
||||||
<div class="ctl">
|
<div class="ctl">
|
||||||
<div class="ctl-head">
|
<div class="ctl-head">
|
||||||
|
|
@ -118,6 +153,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-readout" aria-live="polite" aria-atomic="true">
|
||||||
|
<div class="field-readout-head">
|
||||||
|
<p class="field-readout-label mono">브라우저 데모 해석</p>
|
||||||
|
<p class="field-readout-state" id="fieldState">냉기와 발열이 맞서는 기준 상태입니다.</p>
|
||||||
|
</div>
|
||||||
|
<dl class="field-readout-grid">
|
||||||
|
<div>
|
||||||
|
<dt>장면</dt>
|
||||||
|
<dd id="fieldScene">기준 운전</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>먼저 볼 곳</dt>
|
||||||
|
<dd id="fieldWatch">랙 배면</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>다음 조작</dt>
|
||||||
|
<dd id="fieldAction">부하와 급기를 따로 움직여 비교</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="hero-note">
|
<p class="hero-note">
|
||||||
위 화면은 브라우저에서 도는 <strong>2D 축소판</strong>이다. 슬라이더를 움직이거나 화면을
|
위 화면은 브라우저에서 도는 <strong>2D 축소판</strong>이다. 슬라이더를 움직이거나 화면을
|
||||||
문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 —
|
문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 —
|
||||||
|
|
@ -329,7 +385,15 @@
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div class="vis-shots">
|
<div class="vis-shots">
|
||||||
<figure>
|
<div class="vis-toolbar">
|
||||||
|
<div class="vis-tabs" role="group" aria-label="RFS 실물 파티클 뷰 선택">
|
||||||
|
<button class="vis-tab" type="button" data-vis-target="top" aria-pressed="true">상면</button>
|
||||||
|
<button class="vis-tab" type="button" data-vis-target="side" aria-pressed="false">측면</button>
|
||||||
|
</div>
|
||||||
|
<p class="vis-source mono">RFS 실물 렌더</p>
|
||||||
|
</div>
|
||||||
|
<div class="vis-frame">
|
||||||
|
<figure data-vis-panel="top">
|
||||||
<img
|
<img
|
||||||
src="./assets/sph-topview.webp"
|
src="./assets/sph-topview.webp"
|
||||||
width="689"
|
width="689"
|
||||||
|
|
@ -340,7 +404,7 @@
|
||||||
/>
|
/>
|
||||||
<figcaption>상면 · 랙 상부의 열기둥</figcaption>
|
<figcaption>상면 · 랙 상부의 열기둥</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure>
|
<figure data-vis-panel="side" hidden>
|
||||||
<img
|
<img
|
||||||
src="./assets/sph-sideview.webp"
|
src="./assets/sph-sideview.webp"
|
||||||
width="691"
|
width="691"
|
||||||
|
|
@ -351,6 +415,12 @@
|
||||||
/>
|
/>
|
||||||
<figcaption>측면 · 랙을 통과한 공기</figcaption>
|
<figcaption>측면 · 랙을 통과한 공기</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="temp-key" role="list" aria-label="파티클 온도 색상 범례">
|
||||||
|
<span role="listitem"><i class="temp-cold" aria-hidden="true"></i>저온</span>
|
||||||
|
<span role="listitem"><i class="temp-mid" aria-hidden="true"></i>중간</span>
|
||||||
|
<span role="listitem"><i class="temp-hot" aria-hidden="true"></i>고온</span>
|
||||||
|
</div>
|
||||||
<p class="shots-note">
|
<p class="shots-note">
|
||||||
두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 —
|
두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 —
|
||||||
이 페이지 첫 화면의 색도 같은 규칙을 쓴다.
|
이 페이지 첫 화면의 색도 같은 규칙을 쓴다.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,101 @@
|
||||||
const shell = canvas.closest("[data-field-shell]");
|
const shell = canvas.closest("[data-field-shell]");
|
||||||
const setState = (s) => shell && shell.setAttribute("data-field-state", s);
|
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)");
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
if (reduced.matches) {
|
if (reduced.matches) {
|
||||||
setState("static");
|
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 pointer = { x: 0, y: 0, on: 0 };
|
||||||
const onMove = (e) => {
|
const onMove = (e) => {
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = canvas.getBoundingClientRect();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
@import "./tokens.css";
|
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
RFS — 메인 스타일시트
|
RFS — 메인 스타일시트
|
||||||
|
|
||||||
|
|
@ -361,8 +359,48 @@ h3 {
|
||||||
color: var(--band-ink-muted);
|
color: var(--band-ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-controls {
|
.scenario-strip {
|
||||||
margin-top: var(--space-5);
|
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
|
@ -389,6 +427,58 @@ input[type="range"] {
|
||||||
background: transparent;
|
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 {
|
.hero-note {
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
|
|
@ -692,17 +782,54 @@ input[type="range"] {
|
||||||
|
|
||||||
.vis-shots {
|
.vis-shots {
|
||||||
display: grid;
|
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 {
|
.vis-shots figure {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
border: 1px solid var(--band-line);
|
border: 1px solid var(--band-line);
|
||||||
background: var(--band-surface-2);
|
background: var(--band-surface-2);
|
||||||
|
min-height: clamp(360px, 42vw, 540px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-shots figure[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vis-shots img {
|
.vis-shots img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vis-shots figcaption {
|
.vis-shots figcaption {
|
||||||
|
|
@ -713,6 +840,35 @@ input[type="range"] {
|
||||||
font-family: var(--font-mono);
|
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 {
|
.shots-note {
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
color: var(--band-ink-faint);
|
color: var(--band-ink-faint);
|
||||||
|
|
@ -979,6 +1135,35 @@ kbd {
|
||||||
font-size: 0.6875rem;
|
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 {
|
.figures {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
@ -1014,6 +1199,8 @@ kbd {
|
||||||
.site-head,
|
.site-head,
|
||||||
.hero-stage,
|
.hero-stage,
|
||||||
.hero-controls,
|
.hero-controls,
|
||||||
|
.scenario-strip,
|
||||||
|
.field-readout,
|
||||||
.skip-link {
|
.skip-link {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="./styles/main.css" />
|
<link rel="stylesheet" href="./styles/tokens.css?v=2" />
|
||||||
|
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2316181A'/%3E%3Cpath d='M6 20h20' stroke='%237FA8D9' stroke-width='2'/%3E%3Ccircle cx='13' cy='14' r='4.5' fill='none' stroke='%23F3F1EC' stroke-width='2'/%3E%3Cpath d='M13 9.5V7' stroke='%23F3F1EC' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2316181A'/%3E%3Cpath d='M6 20h20' stroke='%237FA8D9' stroke-width='2'/%3E%3Ccircle cx='13' cy='14' r='4.5' fill='none' stroke='%23F3F1EC' stroke-width='2'/%3E%3Cpath d='M13 9.5V7' stroke='%23F3F1EC' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"
|
||||||
|
|
@ -97,7 +98,21 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">백도 복숭아</th>
|
<th scope="row">
|
||||||
|
<button
|
||||||
|
class="board-select"
|
||||||
|
type="button"
|
||||||
|
data-market-row
|
||||||
|
data-name="백도 복숭아"
|
||||||
|
data-origin="경북 청도"
|
||||||
|
data-grade="특 · 12.5 Bx"
|
||||||
|
data-pack="4.5 kg"
|
||||||
|
data-price="38,000원"
|
||||||
|
data-delta="▲ 2,000원"
|
||||||
|
data-auction="02:00 경매"
|
||||||
|
aria-pressed="true"
|
||||||
|
>백도 복숭아</button>
|
||||||
|
</th>
|
||||||
<td data-label="산지">경북 청도</td>
|
<td data-label="산지">경북 청도</td>
|
||||||
<td data-label="등급" class="c-num mono">특</td>
|
<td data-label="등급" class="c-num mono">특</td>
|
||||||
<td data-label="당도" class="c-num mono">12.5<span class="unit">Bx</span></td>
|
<td data-label="당도" class="c-num mono">12.5<span class="unit">Bx</span></td>
|
||||||
|
|
@ -106,7 +121,21 @@
|
||||||
<td data-label="전일 대비" class="c-num mono delta up">▲ 2,000</td>
|
<td data-label="전일 대비" class="c-num mono delta up">▲ 2,000</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">샤인머스캣</th>
|
<th scope="row">
|
||||||
|
<button
|
||||||
|
class="board-select"
|
||||||
|
type="button"
|
||||||
|
data-market-row
|
||||||
|
data-name="샤인머스캣"
|
||||||
|
data-origin="경북 상주"
|
||||||
|
data-grade="특 · 18.2 Bx"
|
||||||
|
data-pack="2 kg"
|
||||||
|
data-price="26,500원"
|
||||||
|
data-delta="▼ 1,500원"
|
||||||
|
data-auction="02:00 경매"
|
||||||
|
aria-pressed="false"
|
||||||
|
>샤인머스캣</button>
|
||||||
|
</th>
|
||||||
<td data-label="산지">경북 상주</td>
|
<td data-label="산지">경북 상주</td>
|
||||||
<td data-label="등급" class="c-num mono">특</td>
|
<td data-label="등급" class="c-num mono">특</td>
|
||||||
<td data-label="당도" class="c-num mono">18.2<span class="unit">Bx</span></td>
|
<td data-label="당도" class="c-num mono">18.2<span class="unit">Bx</span></td>
|
||||||
|
|
@ -115,7 +144,21 @@
|
||||||
<td data-label="전일 대비" class="c-num mono delta down">▼ 1,500</td>
|
<td data-label="전일 대비" class="c-num mono delta down">▼ 1,500</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">캠벨얼리</th>
|
<th scope="row">
|
||||||
|
<button
|
||||||
|
class="board-select"
|
||||||
|
type="button"
|
||||||
|
data-market-row
|
||||||
|
data-name="캠벨얼리"
|
||||||
|
data-origin="충북 영동"
|
||||||
|
data-grade="상 · 15.4 Bx"
|
||||||
|
data-pack="5 kg"
|
||||||
|
data-price="31,000원"
|
||||||
|
data-delta="보합"
|
||||||
|
data-auction="02:00 경매"
|
||||||
|
aria-pressed="false"
|
||||||
|
>캠벨얼리</button>
|
||||||
|
</th>
|
||||||
<td data-label="산지">충북 영동</td>
|
<td data-label="산지">충북 영동</td>
|
||||||
<td data-label="등급" class="c-num mono">상</td>
|
<td data-label="등급" class="c-num mono">상</td>
|
||||||
<td data-label="당도" class="c-num mono">15.4<span class="unit">Bx</span></td>
|
<td data-label="당도" class="c-num mono">15.4<span class="unit">Bx</span></td>
|
||||||
|
|
@ -124,7 +167,21 @@
|
||||||
<td data-label="전일 대비" class="c-num mono delta flat">보합</td>
|
<td data-label="전일 대비" class="c-num mono delta flat">보합</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">홍로 사과</th>
|
<th scope="row">
|
||||||
|
<button
|
||||||
|
class="board-select"
|
||||||
|
type="button"
|
||||||
|
data-market-row
|
||||||
|
data-name="홍로 사과"
|
||||||
|
data-origin="경북 영주"
|
||||||
|
data-grade="특 · 14.1 Bx"
|
||||||
|
data-pack="5 kg"
|
||||||
|
data-price="44,000원"
|
||||||
|
data-delta="▲ 3,500원"
|
||||||
|
data-auction="08:30 경매"
|
||||||
|
aria-pressed="false"
|
||||||
|
>홍로 사과</button>
|
||||||
|
</th>
|
||||||
<td data-label="산지">경북 영주</td>
|
<td data-label="산지">경북 영주</td>
|
||||||
<td data-label="등급" class="c-num mono">특</td>
|
<td data-label="등급" class="c-num mono">특</td>
|
||||||
<td data-label="당도" class="c-num mono">14.1<span class="unit">Bx</span></td>
|
<td data-label="당도" class="c-num mono">14.1<span class="unit">Bx</span></td>
|
||||||
|
|
@ -133,7 +190,21 @@
|
||||||
<td data-label="전일 대비" class="c-num mono delta up">▲ 3,500</td>
|
<td data-label="전일 대비" class="c-num mono delta up">▲ 3,500</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">원황 배</th>
|
<th scope="row">
|
||||||
|
<button
|
||||||
|
class="board-select"
|
||||||
|
type="button"
|
||||||
|
data-market-row
|
||||||
|
data-name="원황 배"
|
||||||
|
data-origin="전남 나주"
|
||||||
|
data-grade="상 · 12.8 Bx"
|
||||||
|
data-pack="7.5 kg"
|
||||||
|
data-price="52,000원"
|
||||||
|
data-delta="▼ 2,000원"
|
||||||
|
data-auction="08:30 경매"
|
||||||
|
aria-pressed="false"
|
||||||
|
>원황 배</button>
|
||||||
|
</th>
|
||||||
<td data-label="산지">전남 나주</td>
|
<td data-label="산지">전남 나주</td>
|
||||||
<td data-label="등급" class="c-num mono">상</td>
|
<td data-label="등급" class="c-num mono">상</td>
|
||||||
<td data-label="당도" class="c-num mono">12.8<span class="unit">Bx</span></td>
|
<td data-label="당도" class="c-num mono">12.8<span class="unit">Bx</span></td>
|
||||||
|
|
@ -142,7 +213,21 @@
|
||||||
<td data-label="전일 대비" class="c-num mono delta down">▼ 2,000</td>
|
<td data-label="전일 대비" class="c-num mono delta down">▼ 2,000</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">무화과</th>
|
<th scope="row">
|
||||||
|
<button
|
||||||
|
class="board-select"
|
||||||
|
type="button"
|
||||||
|
data-market-row
|
||||||
|
data-name="무화과"
|
||||||
|
data-origin="전남 영암"
|
||||||
|
data-grade="특 · 당도 표기 대상 아님"
|
||||||
|
data-pack="1.2 kg"
|
||||||
|
data-price="24,000원"
|
||||||
|
data-delta="▲ 1,000원"
|
||||||
|
data-auction="02:00 경매"
|
||||||
|
aria-pressed="false"
|
||||||
|
>무화과</button>
|
||||||
|
</th>
|
||||||
<td data-label="산지">전남 영암</td>
|
<td data-label="산지">전남 영암</td>
|
||||||
<td data-label="등급" class="c-num mono">특</td>
|
<td data-label="등급" class="c-num mono">특</td>
|
||||||
<td data-label="당도" class="c-num mono blank" title="당도 표시 대상 품목이 아닙니다">—</td>
|
<td data-label="당도" class="c-num mono blank" title="당도 표시 대상 품목이 아닙니다">—</td>
|
||||||
|
|
@ -153,6 +238,37 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div class="lot-inspector" aria-live="polite" aria-atomic="true">
|
||||||
|
<div class="lot-inspector-head">
|
||||||
|
<p class="mono">선택 로트 읽기</p>
|
||||||
|
<p class="mono" id="lotAuction">02:00 경매</p>
|
||||||
|
</div>
|
||||||
|
<div class="lot-inspector-body">
|
||||||
|
<div class="lot-title">
|
||||||
|
<p id="lotName">백도 복숭아</p>
|
||||||
|
<p id="lotOrigin">경북 청도</p>
|
||||||
|
</div>
|
||||||
|
<dl class="lot-spec">
|
||||||
|
<div>
|
||||||
|
<dt>등급 · 당도</dt>
|
||||||
|
<dd id="lotGrade">특 · 12.5 Bx</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>규격</dt>
|
||||||
|
<dd id="lotPack">4.5 kg</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>낙찰가</dt>
|
||||||
|
<dd id="lotPrice">38,000원</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>전일 대비</dt>
|
||||||
|
<dd id="lotDelta">▲ 2,000원</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="board-notes">
|
<ul class="board-notes">
|
||||||
<li>
|
<li>
|
||||||
사과와 배는 같은 날 <span class="mono">08:30</span> 경매입니다. 나머지 넷은
|
사과와 배는 같은 날 <span class="mono">08:30</span> 경매입니다. 나머지 넷은
|
||||||
|
|
@ -276,6 +392,18 @@
|
||||||
판에 오릅니다.
|
판에 오릅니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="season-control">
|
||||||
|
<div class="season-buttons" role="group" aria-label="제철 달 선택">
|
||||||
|
<button type="button" data-season-dir="-1" aria-label="이전 달">이전 달</button>
|
||||||
|
<button type="button" data-season-dir="1" aria-label="다음 달">다음 달</button>
|
||||||
|
</div>
|
||||||
|
<p class="season-status mono" aria-live="polite" aria-atomic="true">
|
||||||
|
<output id="seasonMonth">8월</output>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span id="seasonCount">제철 품목을 읽는 중입니다</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="season-scroll">
|
<div class="season-scroll">
|
||||||
<table class="season">
|
<table class="season">
|
||||||
<caption class="sr-only">품목별 제철 달력 — 가로축은 1월부터 12월</caption>
|
<caption class="sr-only">품목별 제철 달력 — 가로축은 1월부터 12월</caption>
|
||||||
|
|
@ -507,9 +635,47 @@
|
||||||
<dd>품목당 상자 <span class="mono">3</span> 개부터</dd>
|
<dd>품목당 상자 <span class="mono">3</span> 개부터</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<a class="btn" href="mailto:order@saebyeok-cheonggwa.example?subject=%EB%82%A9%ED%92%88%20%EB%AC%B8%EC%9D%98">
|
|
||||||
납품 조건 문의
|
<section class="order-draft" aria-labelledby="orderDraftHeading">
|
||||||
</a>
|
<div class="order-draft-head">
|
||||||
|
<h3 id="orderDraftHeading">발주 메모 만들기</h3>
|
||||||
|
<p class="mono">쇼케이스 예시</p>
|
||||||
|
</div>
|
||||||
|
<div class="order-controls">
|
||||||
|
<label>
|
||||||
|
<span>품목</span>
|
||||||
|
<select id="orderItem">
|
||||||
|
<option value="백도 복숭아" data-price="38000" data-pack="4.5 kg">백도 복숭아 · 특 · 4.5 kg</option>
|
||||||
|
<option value="샤인머스캣" data-price="26500" data-pack="2 kg">샤인머스캣 · 특 · 2 kg</option>
|
||||||
|
<option value="캠벨얼리" data-price="31000" data-pack="5 kg">캠벨얼리 · 상 · 5 kg</option>
|
||||||
|
<option value="홍로 사과" data-price="44000" data-pack="5 kg">홍로 사과 · 특 · 5 kg</option>
|
||||||
|
<option value="원황 배" data-price="52000" data-pack="7.5 kg">원황 배 · 상 · 7.5 kg</option>
|
||||||
|
<option value="무화과" data-price="24000" data-pack="1.2 kg">무화과 · 특 · 1.2 kg</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>상자 수</span>
|
||||||
|
<input id="orderQty" type="number" min="3" max="50" step="1" value="3" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<dl class="order-summary">
|
||||||
|
<div>
|
||||||
|
<dt>규격</dt>
|
||||||
|
<dd class="mono" id="orderPack">4.5 kg × 3상자</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>예시 낙찰가 합계</dt>
|
||||||
|
<dd class="mono" id="orderTotal">114,000원</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="order-fine">
|
||||||
|
위 합계는 판에 적힌 예시 낙찰가만 더합니다. 선별·포장·배송과 실제 납품가는 포함하지 않습니다.
|
||||||
|
</p>
|
||||||
|
<div class="order-actions">
|
||||||
|
<button class="btn" id="copyOrder" type="button">발주 메모 복사</button>
|
||||||
|
</div>
|
||||||
|
<p class="order-status" id="orderStatus" role="status"></p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -559,14 +725,168 @@
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* 이번 달 칸에 표시를 남긴다. 달이 바뀌면 저절로 따라가야 하므로 이것만 스크립트다. */
|
|
||||||
(() => {
|
(() => {
|
||||||
const m = new Date().getMonth() + 1;
|
const marketTime = new Date();
|
||||||
document.querySelectorAll(`.season [data-m="${m}"]`).forEach((el) => {
|
const dateParts = Object.fromEntries(
|
||||||
el.classList.add("is-now");
|
new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
})
|
||||||
|
.formatToParts(marketTime)
|
||||||
|
.filter((part) => part.type !== "literal")
|
||||||
|
.map((part) => [part.type, part.value]),
|
||||||
|
);
|
||||||
|
const currentMonth = Number(dateParts.month);
|
||||||
|
const stamp = document.querySelector(".hero-stamp time");
|
||||||
|
if (stamp) {
|
||||||
|
stamp.dateTime = `${dateParts.year}-${dateParts.month}-${dateParts.day}T02:00+09:00`;
|
||||||
|
stamp.textContent = `${dateParts.month}월 ${dateParts.day}일 02:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 판의 한 행을 고르면 같은 값으로 로트 판독기가 바뀐다. */
|
||||||
|
const marketRows = [...document.querySelectorAll("[data-market-row]")];
|
||||||
|
const lotFields = {
|
||||||
|
auction: document.getElementById("lotAuction"),
|
||||||
|
name: document.getElementById("lotName"),
|
||||||
|
origin: document.getElementById("lotOrigin"),
|
||||||
|
grade: document.getElementById("lotGrade"),
|
||||||
|
pack: document.getElementById("lotPack"),
|
||||||
|
price: document.getElementById("lotPrice"),
|
||||||
|
delta: document.getElementById("lotDelta"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function showLot(button) {
|
||||||
|
for (const row of marketRows) row.setAttribute("aria-pressed", String(row === button));
|
||||||
|
for (const [key, field] of Object.entries(lotFields)) {
|
||||||
|
if (field) field.textContent = button.dataset[key] || "";
|
||||||
|
}
|
||||||
|
if (orderItem) orderItem.value = button.dataset.name || orderItem.value;
|
||||||
|
updateOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of marketRows) row.addEventListener("click", () => showLot(row));
|
||||||
|
|
||||||
|
/* 제철 띠는 이번 달에서 시작하지만 앞뒤 달도 직접 읽을 수 있다. */
|
||||||
|
let selectedMonth = currentMonth;
|
||||||
|
const season = document.querySelector(".season");
|
||||||
|
const seasonScroll = document.querySelector(".season-scroll");
|
||||||
|
const seasonMonth = document.getElementById("seasonMonth");
|
||||||
|
const seasonCount = document.getElementById("seasonCount");
|
||||||
|
|
||||||
|
function showMonth(month, moveViewport) {
|
||||||
|
selectedMonth = month;
|
||||||
|
season?.querySelectorAll(".is-focus").forEach((cell) => cell.classList.remove("is-focus"));
|
||||||
|
season?.querySelectorAll("[aria-current='date']").forEach((cell) => cell.removeAttribute("aria-current"));
|
||||||
|
season?.querySelectorAll(`[data-m="${month}"]`).forEach((cell) => cell.classList.add("is-focus"));
|
||||||
|
|
||||||
|
const head = season?.querySelector(`thead [data-m="${month}"]`);
|
||||||
|
if (month === currentMonth) head?.setAttribute("aria-current", "date");
|
||||||
|
|
||||||
|
const items = [...(season?.querySelectorAll("tbody tr") || [])]
|
||||||
|
.filter((row) => row.querySelector(`td.on[data-m="${month}"]`))
|
||||||
|
.map((row) => row.querySelector("th")?.textContent.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (seasonMonth) seasonMonth.textContent = `${month}월`;
|
||||||
|
if (seasonCount) seasonCount.textContent = `${items.length}품목 · ${items.join(" · ")}`;
|
||||||
|
|
||||||
|
if (moveViewport && head && seasonScroll) {
|
||||||
|
const left = Math.max(0, head.offsetLeft - seasonScroll.clientWidth / 2 + head.clientWidth / 2);
|
||||||
|
seasonScroll.scrollTo({ left, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-season-dir]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const next = ((selectedMonth - 1 + Number(button.dataset.seasonDir) + 12) % 12) + 1;
|
||||||
|
showMonth(next, true);
|
||||||
});
|
});
|
||||||
const head = document.querySelector(`.season thead [data-m="${m}"]`);
|
});
|
||||||
if (head) head.setAttribute("aria-current", "date");
|
showMonth(currentMonth, false);
|
||||||
|
|
||||||
|
/* 낙찰가 예시로 발주 메모를 만든다. 납품가처럼 보이지 않게 항목을 분리한다. */
|
||||||
|
const orderItem = document.getElementById("orderItem");
|
||||||
|
const orderQty = document.getElementById("orderQty");
|
||||||
|
const orderPack = document.getElementById("orderPack");
|
||||||
|
const orderTotal = document.getElementById("orderTotal");
|
||||||
|
const orderStatus = document.getElementById("orderStatus");
|
||||||
|
|
||||||
|
function orderValues() {
|
||||||
|
const option = orderItem?.selectedOptions[0];
|
||||||
|
const quantity = Math.min(50, Math.max(3, Number(orderQty?.value) || 3));
|
||||||
|
const price = Number(option?.dataset.price) || 0;
|
||||||
|
return {
|
||||||
|
name: option?.value || "",
|
||||||
|
pack: option?.dataset.pack || "",
|
||||||
|
quantity,
|
||||||
|
total: price * quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOrder() {
|
||||||
|
const value = orderValues();
|
||||||
|
if (orderPack) orderPack.textContent = `${value.pack} × ${value.quantity}상자`;
|
||||||
|
if (orderTotal) orderTotal.textContent = `${value.total.toLocaleString("ko-KR")}원`;
|
||||||
|
if (orderStatus) orderStatus.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
orderItem?.addEventListener("change", updateOrder);
|
||||||
|
orderQty?.addEventListener("input", updateOrder);
|
||||||
|
orderQty?.addEventListener("change", () => {
|
||||||
|
if (orderQty) orderQty.value = String(orderValues().quantity);
|
||||||
|
updateOrder();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyText(text) {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
navigator.clipboard.writeText(text),
|
||||||
|
new Promise((_, reject) => window.setTimeout(() => reject(new Error("clipboard timeout")), 1200)),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 권한 대화상자가 없거나 응답하지 않는 브라우저는 아래 선택 복사로 전환합니다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = document.createElement("textarea");
|
||||||
|
field.value = text;
|
||||||
|
field.setAttribute("readonly", "");
|
||||||
|
field.style.position = "fixed";
|
||||||
|
field.style.inset = "0 auto auto -9999px";
|
||||||
|
document.body.append(field);
|
||||||
|
field.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
field.remove();
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("copyOrder")?.addEventListener("click", async () => {
|
||||||
|
const value = orderValues();
|
||||||
|
const memo = [
|
||||||
|
"새벽청과 납품 문의",
|
||||||
|
`품목: ${value.name}`,
|
||||||
|
`규격: ${value.pack}`,
|
||||||
|
`수량: ${value.quantity}상자`,
|
||||||
|
`예시 낙찰가 합계: ${value.total.toLocaleString("ko-KR")}원`,
|
||||||
|
"실제 납품가는 선별·포장·배송 조건을 확인한 뒤 정합니다.",
|
||||||
|
].join("\n");
|
||||||
|
try {
|
||||||
|
const copied = await copyText(memo);
|
||||||
|
if (orderStatus) {
|
||||||
|
orderStatus.textContent = copied
|
||||||
|
? "발주 메모를 복사했습니다."
|
||||||
|
: "복사하지 못했습니다. 브라우저의 클립보드 권한을 확인해 주세요.";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (orderStatus) orderStatus.textContent = "복사하지 못했습니다. 브라우저의 클립보드 권한을 확인해 주세요.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateOrder();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
@import "./tokens.css";
|
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
새벽청과 — 메인 스타일시트
|
새벽청과 — 메인 스타일시트
|
||||||
|
|
||||||
|
|
@ -42,6 +40,14 @@ img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +311,25 @@ h3 {
|
||||||
white-space: nowrap;
|
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 {
|
.board td {
|
||||||
color: var(--band-ink-muted);
|
color: var(--band-ink-muted);
|
||||||
}
|
}
|
||||||
|
|
@ -340,6 +365,67 @@ h3 {
|
||||||
color: var(--band-ink-faint);
|
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 {
|
.board-notes {
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -497,10 +583,53 @@ h3 {
|
||||||
|
|
||||||
/* ── 제철 띠 ────────────────────────────────────────────────────── */
|
/* ── 제철 띠 ────────────────────────────────────────────────────── */
|
||||||
.season-scroll {
|
.season-scroll {
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-3);
|
||||||
overflow-x: auto;
|
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 {
|
.season {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 34rem;
|
min-width: 34rem;
|
||||||
|
|
@ -541,11 +670,11 @@ h3 {
|
||||||
|
|
||||||
/* 이번 달 — 배경 대신 테두리로 표시한다. 칸이 찬 것과 이번 달이 겹쳐도
|
/* 이번 달 — 배경 대신 테두리로 표시한다. 칸이 찬 것과 이번 달이 겹쳐도
|
||||||
둘 다 읽혀야 하기 때문이다 */
|
둘 다 읽혀야 하기 때문이다 */
|
||||||
.season .is-now {
|
.season .is-focus {
|
||||||
border-inline: 2px solid var(--band-accent);
|
border-inline: 2px solid var(--band-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.season thead .is-now {
|
.season thead .is-focus {
|
||||||
color: var(--band-accent);
|
color: var(--band-accent);
|
||||||
border-top: 2px solid var(--band-accent);
|
border-top: 2px solid var(--band-accent);
|
||||||
}
|
}
|
||||||
|
|
@ -677,6 +806,99 @@ h3 {
|
||||||
font-weight: 600;
|
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 {
|
.contact-fine {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
|
|
@ -687,14 +909,16 @@ h3 {
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
min-height: 44px;
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
padding: var(--space-3) var(--space-5);
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border: 0;
|
||||||
background: var(--band-accent);
|
background: var(--band-accent);
|
||||||
color: var(--band-accent-ink);
|
color: var(--band-accent-ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background-color var(--dur-quick) var(--ease);
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
|
|
@ -772,6 +996,10 @@ h3 {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-select {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.board td {
|
.board td {
|
||||||
padding: var(--space-1) 0;
|
padding: var(--space-1) 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
@ -787,6 +1015,37 @@ h3 {
|
||||||
font-family: var(--font-body);
|
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 {
|
.today-card-wide {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -812,6 +1071,10 @@ h3 {
|
||||||
.site-head,
|
.site-head,
|
||||||
.skip-link,
|
.skip-link,
|
||||||
.btn,
|
.btn,
|
||||||
|
.season-control,
|
||||||
|
.order-controls,
|
||||||
|
.order-actions,
|
||||||
|
.order-status,
|
||||||
.hero-bg {
|
.hero-bg {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# E2E 시나리오 게이트 — 2026-08-24 17:13
|
# E2E 시나리오 게이트 — 2026-08-24 19:39
|
||||||
|
|
||||||
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**
|
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**
|
||||||
|
|
||||||
|
|
|
||||||
375
apps/site/tools/recent-showcases-e2e.mjs
Normal file
375
apps/site/tools/recent-showcases-e2e.mjs
Normal file
|
|
@ -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);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# TWENTYOZ E2E — 2026-08-24 17:13
|
# TWENTYOZ E2E — 2026-08-24 19:39
|
||||||
|
|
||||||
**148 assertions passed · GREEN**
|
**148 assertions passed · GREEN**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-자산/트웬티온스 3D | PASS | PASS — | 0.2s |
|
||||||
| L0-정적/stylelint | PASS | PASS — | 5.2s |
|
| L0-정적/stylelint | PASS | PASS — | 1.7s |
|
||||||
| L0-정식/html-validate | PASS | PASS — | 2.9s |
|
| L0-정식/html-validate | PASS | PASS — | 1.5s |
|
||||||
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.3s |
|
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.2s |
|
||||||
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
|
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
|
||||||
| L2-기능/감사 | PASS | PASS 72 | 10.9s |
|
| L2-기능/감사 | PASS | PASS 72 | 9.8s |
|
||||||
| L2-불변식/렌더 | PASS | PASS 10 | 16.9s |
|
| L2-불변식/렌더 | PASS | PASS 10 | 17.2s |
|
||||||
| L2-디자인 게이트/메인 | PASS | PASS 130 | 33.0s |
|
| L2-디자인 게이트/메인 | PASS | PASS 130 | 32.1s |
|
||||||
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.0s |
|
| L2-디자인 게이트/제품 | PASS | PASS 15 | 6.8s |
|
||||||
| L3-시각회귀/diff | PASS | PASS 30 | 12.0s |
|
| L3-시각회귀/diff | PASS | PASS 30 | 11.3s |
|
||||||
| L4-WebKit | PASS | PASS 8 | 4.5s |
|
| L4-WebKit | PASS | PASS 8 | 3.5s |
|
||||||
| L5-탐색/키보드 | PASS | PASS 14 | 6.4s |
|
| L5-탐색/키보드 | PASS | PASS 14 | 6.1s |
|
||||||
| L6-시나리오/E2E | PASS | PASS — | 43.2s |
|
| L6-시나리오/E2E | PASS | PASS — | 42.6s |
|
||||||
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 23.0s |
|
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 10.8s |
|
||||||
|
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.7s |
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const steps = [
|
||||||
{ layer: "L4-WebKit", cmd: () => spawnSync(process.execPath, ["tools/webkit.mjs"], { cwd: root, encoding: "utf8" }) },
|
{ 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: "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-시나리오/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" }) },
|
{ layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue