Upgrade RFS and Saebyeok showcases
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-08-25 04:41:51 +09:00
parent 9529e3747c
commit 413486958c
13 changed files with 1410 additions and 88 deletions

View file

@ -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에 포함한다. 배포 전에는 전체 게이트를 다시 실행한다.

View file

@ -1,4 +1,4 @@
# 게이트 리포트 — 2026-08-24 17:12 # 게이트 리포트 — 2026-08-24 19:37
**전 항목 통과 — 배포 가능** **전 항목 통과 — 배포 가능**

View file

@ -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",

View file

@ -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 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 —
이 페이지 첫 화면의 색도 같은 규칙을 쓴다. 이 페이지 첫 화면의 색도 같은 규칙을 쓴다.

View file

@ -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();

View file

@ -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;
} }

View file

@ -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>

View file

@ -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;
} }

View file

@ -1,4 +1,4 @@
# E2E 시나리오 게이트 — 2026-08-24 17:13 # E2E 시나리오 게이트 — 2026-08-24 19:39
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과** 단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**

View 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);

View file

@ -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**

View file

@ -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 |

View file

@ -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" }) },
]; ];