diff --git a/.gitignore b/.gitignore index 9a2719e..7400689 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ research/youtube/*.vtt apps/site/tools/.gen/ .gemini/ + +# 이미지 생성 스테이징 (원본 PNG 는 커밋하지 않는다 — webp 만 싣는다) +apps/site/tools/.gen-fruit/ + +# playwright MCP 임시 산출물 +.playwright-mcp/ diff --git a/apps/site/public/work/cask-archive/docent/index.html b/apps/site/public/work/cask-archive/docent/index.html index c636078..1860f83 100644 --- a/apps/site/public/work/cask-archive/docent/index.html +++ b/apps/site/public/work/cask-archive/docent/index.html @@ -73,7 +73,6 @@ font-family: var(--font-mono); font-size: var(--step--1); color: var(--ink-muted); - text-transform: uppercase; } .docent-meta-item .value { font-size: var(--step-0); @@ -176,19 +175,19 @@
-
Session Type
+
Session Type
1:1 Private Tasting
-
Location
+
Location
Cask Room (한남 27길)
-
Capacity
+
Capacity
최대 2인 (동반 1인 가능)
-
Glassware
+
Glassware
Glencairn & Copita
@@ -220,7 +219,7 @@ />
-
SENSORY ANALYSIS
+
관능 분석

후각과 미각의 지도를 그리는
4축 아로마 분석법 @@ -245,7 +244,7 @@
-
CURATED FLIGHT SELECTION
+
큐레이션 플라이트

도슨트 테이스팅 정규 코스

@@ -255,7 +254,7 @@
- COURSE A + 코스 A 65,000원

@@ -302,7 +301,7 @@
- COURSE B + 코스 B 120,000원

@@ -372,7 +371,7 @@
-
GASTRONOMY PAIRING
+
미식 페어링

향과 질감의 증폭을 위한
아티잔 푸드 페어링 @@ -395,7 +394,7 @@
-
RESERVATION REQUEST
+
예약 신청

도슨트 테이스팅 세션 실시간 예약

diff --git a/apps/site/public/work/cask-archive/styles/main.css b/apps/site/public/work/cask-archive/styles/main.css index 43458b1..e0a7f16 100644 --- a/apps/site/public/work/cask-archive/styles/main.css +++ b/apps/site/public/work/cask-archive/styles/main.css @@ -39,7 +39,7 @@ img { a { color: inherit; text-decoration: none; - transition: color var(--dur-fast); + transition: color var(--dur-quick); } a:hover { @@ -100,9 +100,9 @@ button { padding: 0.5rem 1rem; background: var(--accent); color: var(--accent-ink); - border-radius: var(--radius-xs); + border-radius: var(--radius); transform: translateY(-200%); - transition: transform var(--dur-fast); + transition: transform var(--dur-quick); text-decoration: none; font-weight: 700; } @@ -162,7 +162,7 @@ button { color: var(--ink-muted); font-size: var(--step--1); font-weight: 500; - transition: color var(--dur-fast); + transition: color var(--dur-quick); white-space: nowrap; } @@ -187,8 +187,8 @@ button { font-weight: 700; font-size: var(--step--1); padding: 6px 14px; - border-radius: var(--radius-xs); - transition: opacity var(--dur-fast), transform var(--dur-fast); + border-radius: var(--radius); + transition: opacity var(--dur-quick), transform var(--dur-quick); } .head-actions .btn:hover { @@ -205,12 +205,12 @@ button { background: var(--accent); color: #0b0b0d; padding: 10px 20px; - border-radius: var(--radius-xs); + border-radius: var(--radius); font-weight: 700; font-size: var(--step--1); text-decoration: none; white-space: nowrap; - transition: transform var(--dur-fast), background var(--dur-fast), opacity var(--dur-fast); + transition: transform var(--dur-quick), background var(--dur-quick), opacity var(--dur-quick); border: 1px solid var(--accent); } @@ -229,12 +229,12 @@ button { color: var(--ink); border: 1px solid var(--line); padding: 10px 18px; - border-radius: var(--radius-xs); + border-radius: var(--radius); font-weight: 600; font-size: var(--step--1); text-decoration: none; white-space: nowrap; - transition: border-color var(--dur-fast), color var(--dur-fast); + transition: border-color var(--dur-quick), color var(--dur-quick); } .btn-outline:hover { @@ -247,7 +247,7 @@ button { align-items: center; gap: 6px; padding: 4px 10px; - border-radius: var(--radius-xs); + border-radius: var(--radius); background: rgba(217, 155, 56, 0.12); color: var(--accent); font-size: 11px; @@ -256,7 +256,6 @@ button { letter-spacing: 0.08em; margin-bottom: var(--space-3); border: 1px solid rgba(217, 155, 56, 0.25); - text-transform: uppercase; } /* ── 히어로 섹션 ── */ @@ -332,7 +331,7 @@ button { padding: 6px 12px; background: var(--surface-raised); border: 1px solid var(--line); - border-radius: var(--radius-xs); + border-radius: var(--radius); display: flex; justify-content: space-between; align-items: baseline; @@ -434,13 +433,13 @@ button { .filter-btn { padding: 6px 14px; - border-radius: var(--radius-xs); + border-radius: var(--radius); background: var(--surface-raised); border: 1px solid var(--line); color: var(--ink-muted); font-size: 12px; font-weight: 600; - transition: all var(--dur-fast); + transition: all var(--dur-quick); } .filter-btn:hover { @@ -459,11 +458,11 @@ button { border: 1px solid var(--line); color: var(--ink); padding: 8px 14px; - border-radius: var(--radius-xs); + border-radius: var(--radius); font-size: 13px; min-width: 240px; outline: none; - transition: border-color var(--dur-fast); + transition: border-color var(--dur-quick); } .search-input:focus { @@ -529,7 +528,7 @@ button { } .ledger-table tbody tr { - transition: background var(--dur-fast); + transition: background var(--dur-quick); } .ledger-table tbody tr:hover { @@ -583,7 +582,7 @@ button { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: var(--space-5); - transition: border-color var(--dur-fast); + transition: border-color var(--dur-quick); } .course-card:hover { @@ -632,7 +631,6 @@ button { font-family: var(--font-mono); font-weight: 600; color: var(--ink-muted); - text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } @@ -643,11 +641,11 @@ button { padding: 10px 12px; background: var(--surface); border: 1px solid var(--line); - border-radius: var(--radius-xs); + border-radius: var(--radius); color: var(--ink); font-size: 13px; outline: none; - transition: border-color var(--dur-fast); + transition: border-color var(--dur-quick); } .form-select:focus, @@ -686,7 +684,7 @@ button { padding: 10px 14px; background: rgba(217, 155, 56, 0.1); border: 1px solid rgba(217, 155, 56, 0.25); - border-radius: var(--radius-xs); + border-radius: var(--radius); min-width: 80px; flex-shrink: 0; } diff --git a/apps/site/public/work/cheongon-care/index.html b/apps/site/public/work/cheongon-care/index.html index 4354ff6..e855d22 100644 --- a/apps/site/public/work/cheongon-care/index.html +++ b/apps/site/public/work/cheongon-care/index.html @@ -76,7 +76,7 @@
- HOSPITAL-GRADE HYGIENE PROTOCOL + 임상 수준 위생 프로토콜

보이지 않는 미세 오염까지,
@@ -355,7 +355,7 @@
-
OPERATIONAL CONTROL CENTER
+
배차 관제 콘솔

실시간 배차 및 현장 관제 현황

-
SCIENTIFIC DATA VERIFICATION
+
ATP 데이터 검증

나인원한남 86평형
ATP 생체 오염도 99.1% 저감 리포트 @@ -238,7 +237,7 @@
-
STEP-BY-STEP ENGINEERING
+
단계별 시공 절차

청온 케어 임상 6단계 시공 체계

@@ -320,7 +319,7 @@
-
TRANSPARENT PRICING
+
정찰제 가격표

우리 집 공간의 정찰제 견적을 확인하세요

diff --git a/apps/site/public/work/cheongon-care/styles/main.css b/apps/site/public/work/cheongon-care/styles/main.css index 1bf5bd4..1aa8943 100644 --- a/apps/site/public/work/cheongon-care/styles/main.css +++ b/apps/site/public/work/cheongon-care/styles/main.css @@ -39,7 +39,7 @@ img { a { color: inherit; text-decoration: none; - transition: color var(--dur-fast); + transition: color var(--dur-quick); } a:hover { @@ -89,7 +89,7 @@ button { color: var(--ink-inverse); border-radius: var(--radius-sm); transform: translateY(-200%); - transition: transform var(--dur-fast); + transition: transform var(--dur-quick); text-decoration: none; font-weight: 500; } @@ -136,7 +136,7 @@ button { font-size: 11px; font-weight: 700; padding: 2px 6px; - border-radius: var(--radius-xs); + border-radius: var(--radius); } .wordmark-han { @@ -158,7 +158,7 @@ button { color: var(--ink-muted); font-size: var(--step--1); font-weight: 500; - transition: color var(--dur-fast); + transition: color var(--dur-quick); white-space: nowrap; } @@ -193,7 +193,7 @@ button { display: inline-flex; align-items: center; gap: 5px; - transition: all var(--dur-fast); + transition: all var(--dur-quick); white-space: nowrap; } @@ -219,8 +219,8 @@ button { font-weight: 700; font-size: var(--step--1); padding: 6px 14px; - border-radius: var(--radius-xs); - transition: opacity var(--dur-fast); + border-radius: var(--radius); + transition: opacity var(--dur-quick); } .head-actions .btn:hover { @@ -241,7 +241,7 @@ button { font-size: var(--step--1); text-decoration: none; white-space: nowrap; - transition: transform var(--dur-fast), background var(--dur-fast); + transition: transform var(--dur-quick), background var(--dur-quick); border: none; } @@ -265,7 +265,7 @@ button { font-size: var(--step--1); text-decoration: none; white-space: nowrap; - transition: border-color var(--dur-fast), color var(--dur-fast); + transition: border-color var(--dur-quick), color var(--dur-quick); } .btn-outline:hover { @@ -278,15 +278,14 @@ button { align-items: center; gap: 6px; padding: 3px 10px; - border-radius: var(--radius-xs); + border-radius: var(--radius); background: var(--accent-light); color: var(--accent); font-size: 11px; - font-family: var(--font-mono); + font-family: var(--font-body); font-weight: 700; - letter-spacing: 0.06em; + letter-spacing: 0.02em; margin-bottom: var(--space-3); - text-transform: uppercase; } /* ── 히어로 섹션 ── */ @@ -464,7 +463,7 @@ button { font-weight: 600; font-size: var(--step--1); text-align: center; - transition: all var(--dur-fast); + transition: all var(--dur-quick); white-space: normal; word-break: keep-all; } @@ -514,7 +513,7 @@ button { font-size: var(--step--1); color: var(--ink-muted); cursor: pointer; - transition: all var(--dur-fast); + transition: all var(--dur-quick); } .addon-label:has(input:checked) { @@ -583,7 +582,7 @@ button { font-weight: 700; font-size: var(--step-0); border-radius: var(--radius-sm); - transition: background var(--dur-fast); + transition: background var(--dur-quick); } .calc-submit:hover { @@ -694,7 +693,7 @@ button { position: absolute; bottom: 12px; padding: 4px 10px; - border-radius: var(--radius-xs); + border-radius: var(--radius); font-size: 11px; font-family: var(--font-mono); font-weight: 700; @@ -793,7 +792,7 @@ button { .filter-tab { padding: 3px 8px; - border-radius: var(--radius-xs); + border-radius: var(--radius); font-size: 11px; font-weight: 600; background: var(--surface); @@ -834,7 +833,7 @@ button { display: inline-flex; align-items: center; padding: 2px 8px; - border-radius: var(--radius-xs); + border-radius: var(--radius); font-size: 11px; font-weight: 700; } @@ -846,12 +845,12 @@ button { .action-btn { padding: 4px 10px; border: 1px solid var(--line); - border-radius: var(--radius-xs); + border-radius: var(--radius); font-size: 12px; font-weight: 600; background: var(--surface); cursor: pointer; - transition: all var(--dur-fast); + transition: all var(--dur-quick); } .action-btn:hover { diff --git a/apps/site/public/work/font-foundry/index.html b/apps/site/public/work/font-foundry/index.html index f0c4ada..ccbc54f 100644 --- a/apps/site/public/work/font-foundry/index.html +++ b/apps/site/public/work/font-foundry/index.html @@ -129,15 +129,15 @@
- +
17
- +
0
- +
1.8
diff --git a/apps/site/public/work/font-foundry/styles/main.css b/apps/site/public/work/font-foundry/styles/main.css index 3981f9e..1eba5c8 100644 --- a/apps/site/public/work/font-foundry/styles/main.css +++ b/apps/site/public/work/font-foundry/styles/main.css @@ -235,9 +235,12 @@ a { display: grid; gap: var(--space-4); } -.control label { +.control-head { display: flex; justify-content: space-between; + align-items: baseline; +} +.control label { font-size: var(--step--1); color: var(--ink-muted); } diff --git a/apps/site/public/work/lumina-optics/index.html b/apps/site/public/work/lumina-optics/index.html index 9288a45..99356f0 100644 --- a/apps/site/public/work/lumina-optics/index.html +++ b/apps/site/public/work/lumina-optics/index.html @@ -20,7 +20,7 @@
- +
1.52
- +
0.18

Drag the prism to rotate · beam is collimated at 632.8 nm reference

diff --git a/apps/site/public/work/lumina-optics/styles/main.css b/apps/site/public/work/lumina-optics/styles/main.css index 0204a93..7c86c47 100644 --- a/apps/site/public/work/lumina-optics/styles/main.css +++ b/apps/site/public/work/lumina-optics/styles/main.css @@ -26,6 +26,8 @@ html { } body { + word-break: keep-all; + overflow-wrap: break-word; background: var(--surface); color: var(--ink); font-family: var(--font-body); @@ -193,9 +195,12 @@ a { display: grid; gap: var(--space-3); } -.hud-row label { +.hud-row-head { display: flex; justify-content: space-between; + align-items: baseline; +} +.hud-row label { gap: var(--space-3); font-size: var(--step--1); color: var(--ink-muted); diff --git a/apps/site/public/work/lumina-optics/styles/tokens.css b/apps/site/public/work/lumina-optics/styles/tokens.css index ec9e5e6..113ce01 100644 --- a/apps/site/public/work/lumina-optics/styles/tokens.css +++ b/apps/site/public/work/lumina-optics/styles/tokens.css @@ -23,7 +23,7 @@ --leading-tight: 1.24; --leading-normal: 1.72; - --font-body: "IBM Plex Sans", -apple-system, sans-serif; + --font-body: "IBM Plex Sans", "IBM Plex Sans KR", Pretendard, -apple-system, sans-serif; --font-mono: "JetBrains Mono", monospace; /* 색 — 시험실 무채색. 살짝 청록 기운의 액센트는 '레이저 라인' 하나뿐. */ diff --git a/apps/site/public/work/materia-interior/hannam-duplex.html b/apps/site/public/work/materia-interior/hannam-duplex.html index 55cdb8c..856f551 100644 --- a/apps/site/public/work/materia-interior/hannam-duplex.html +++ b/apps/site/public/work/materia-interior/hannam-duplex.html @@ -1,14 +1,39 @@ - + + - 한남 듀플렉스 펜트하우스 이동 중... + 한남 듀플렉스 펜트하우스 — 이동 중 + + + - -

한남 듀플렉스 펜트하우스 갤러리로 이동 중입니다... 직접 이동하기

+ +

+ 한남 듀플렉스 펜트하우스 갤러리로 이동 중입니다. + 바로 이동 +

diff --git a/apps/site/public/work/materia-interior/index.html b/apps/site/public/work/materia-interior/index.html index 1e4af47..cadd9f6 100644 --- a/apps/site/public/work/materia-interior/index.html +++ b/apps/site/public/work/materia-interior/index.html @@ -76,7 +76,7 @@
- HIGH-END ARCHITECTURAL INTERIOR + 하이엔드 공간 인테리어

빛과 트래버틴이 빚어내는
@@ -293,7 +293,7 @@
-
BESPOKE CONSULTATION
+
프라이빗 컨설팅

프라이빗 공간 컨설팅 신청

diff --git a/apps/site/public/work/materia-interior/projects/hannam-duplex/index.html b/apps/site/public/work/materia-interior/projects/hannam-duplex/index.html index 5e0d195..6f24846 100644 --- a/apps/site/public/work/materia-interior/projects/hannam-duplex/index.html +++ b/apps/site/public/work/materia-interior/projects/hannam-duplex/index.html @@ -98,7 +98,6 @@ font-family: var(--font-mono); font-size: var(--step--1); color: var(--ink-muted); - text-transform: uppercase; letter-spacing: 0.05em; } .meta-item .value { @@ -256,8 +255,7 @@ font-size: 11px; color: var(--accent); font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.02em; } .gallery-card-title { font-family: var(--font-serif); @@ -397,27 +395,27 @@
-
위치
+
위치
서울시 용산구 한남동
-
면적
+
면적
284㎡ (86평형 복층)
-
천장고
+
천장고
5.8m Void Ceiling
-
공사기간
+
공사기간
14주 (2026.04 준공)
-
핵심 소재
+
핵심 소재
Roman Navona Travertine
-
목공 마감
+
목공 마감
3,000Y Smoked Bog Oak
@@ -444,7 +442,7 @@
-
DESIGN PHILOSOPHY
+
설계 관점

비움으로 완성한
빛과 물성의 교감

@@ -467,7 +465,7 @@ -
+
국립현대미술관DDP서울시립교향악단아르코예술극장제주국제음악제국립현대미술관DDP서울시립교향악단아르코예술극장제주국제음악제
diff --git a/apps/site/public/work/pulsegate/index.html b/apps/site/public/work/pulsegate/index.html index 54e5a48..0e26efc 100644 --- a/apps/site/public/work/pulsegate/index.html +++ b/apps/site/public/work/pulsegate/index.html @@ -20,7 +20,7 @@ -
+
ICN-01 · Seoul14.2 ms
NRT-02 · Tokyo28.6 ms
SIN-01 · Singapore34.0 ms
diff --git a/apps/site/public/work/pulsegate/styles/main.css b/apps/site/public/work/pulsegate/styles/main.css index e972ff1..14529ee 100644 --- a/apps/site/public/work/pulsegate/styles/main.css +++ b/apps/site/public/work/pulsegate/styles/main.css @@ -27,6 +27,8 @@ html { } body { + word-break: keep-all; + overflow-wrap: break-word; background: var(--surface); color: var(--ink); font-family: var(--font-body); diff --git a/apps/site/public/work/pulsegate/styles/tokens.css b/apps/site/public/work/pulsegate/styles/tokens.css index 04500ac..552208a 100644 --- a/apps/site/public/work/pulsegate/styles/tokens.css +++ b/apps/site/public/work/pulsegate/styles/tokens.css @@ -21,7 +21,7 @@ --leading-tight: 1.22; --leading-normal: 1.7; - --font-body: "IBM Plex Sans", -apple-system, sans-serif; + --font-body: "IBM Plex Sans", "IBM Plex Sans KR", Pretendard, -apple-system, sans-serif; --font-mono: "JetBrains Mono", monospace; /* 색 — 야간 당직실. 위계는 명도차로만. 코발트는 '잉크'다: 글자와 가는 선. */ diff --git a/apps/site/public/work/synapse-bci/index.html b/apps/site/public/work/synapse-bci/index.html index c14831c..409b748 100644 --- a/apps/site/public/work/synapse-bci/index.html +++ b/apps/site/public/work/synapse-bci/index.html @@ -20,7 +20,7 @@ m.replace(/[^\n]/g, "")); +} + +/** :root 등에서 정의된 커스텀 프로퍼티 이름을 모은다. */ +function collectDefined(css) { + const names = new Set(); + // 선언 위치에서만 잡는다: `--name:` 형태이되 var( 안이 아닌 것. + for (const m of css.matchAll(/(^|[;{]\s*)(--[A-Za-z0-9_-]+)\s*:/g)) { + names.add(m[2]); + } + return names; +} + +/** + * 폴백이 없는 var(--name) 사용처만 모은다. + * `var(--delay, 0ms)` 처럼 폴백을 둔 것은 요소별로 인라인 주입하는 선택적 변수이므로 + * 정의가 없어도 선언이 무효가 되지 않는다 — 결함이 아니다. + */ +function collectUsed(css) { + const uses = []; + for (const m of css.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*([,)])/g)) { + if (m[2] === ",") continue; + uses.push({ name: m[1], index: m.index }); + } + return uses; +} + +function lineOf(text, index) { + return text.slice(0, index).split("\n").length; +} + +const showcases = fs + .readdirSync(workRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + +if (showcases.length === 0) fail("쇼케이스 탐색", "public/work 아래에 디렉터리가 없다"); + +/* ── 1. CSS 커스텀 프로퍼티 참조 무결성 ── */ +for (const slug of showcases) { + const stylesDir = path.join(workRoot, slug, "styles"); + if (!fs.existsSync(stylesDir)) continue; + + const cssFiles = fs + .readdirSync(stylesDir) + .filter((f) => f.endsWith(".css")) + .map((f) => path.join(stylesDir, f)); + + // 한 쇼케이스의 CSS 는 @import 로 묶여 한 문서가 되므로 정의를 합집합으로 본다. + const merged = cssFiles.map((f) => stripComments(fs.readFileSync(f, "utf8"))); + const defined = new Set(); + for (const css of merged) for (const n of collectDefined(css)) defined.add(n); + + // 미디어쿼리·@supports 안에서만 정의되는 변수도 정의로 친다(위 정규식이 이미 포함). + const undefinedUses = []; + cssFiles.forEach((file, i) => { + for (const use of collectUsed(merged[i])) { + if (!defined.has(use.name)) { + undefinedUses.push(`${path.relative(root, file)}:${lineOf(merged[i], use.index)} ${use.name}`); + } + } + }); + + if (undefinedUses.length) { + const shown = undefinedUses.slice(0, 6).join(", "); + fail( + `${slug} CSS 변수 정의`, + `미정의 ${undefinedUses.length}건 — ${shown}${undefinedUses.length > 6 ? " …" : ""}`, + ); + } else { + ok(`${slug} CSS 변수 정의`); + } +} + +/* ── 2. 정적 경로 무결성 (로컬 상대 경로만) ── */ +const localRef = /(?:href|src)\s*=\s*"(\.\/[^"#?]+|\.\.\/[^"#?]+)"/g; +const cssUrlRef = /url\(\s*["']?(\.{1,2}\/[^"')?#]+)/g; + +for (const slug of showcases) { + const dir = path.join(workRoot, slug); + const broken = []; + + const walk = (d) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(p); + continue; + } + const isHtml = entry.name.endsWith(".html"); + const isCss = entry.name.endsWith(".css"); + if (!isHtml && !isCss) continue; + + const text = fs.readFileSync(p, "utf8"); + const body = isCss ? stripComments(text) : text; + const pattern = isHtml ? localRef : cssUrlRef; + pattern.lastIndex = 0; + for (const m of body.matchAll(pattern)) { + const target = path.resolve(path.dirname(p), m[1]); + if (!fs.existsSync(target)) { + broken.push(`${path.relative(root, p)}:${lineOf(body, m.index)} → ${m[1]}`); + } + } + } + }; + walk(dir); + + if (broken.length) { + fail(`${slug} 경로 참조`, `끊긴 링크 ${broken.length}건 — ${broken.slice(0, 5).join(", ")}`); + } else { + ok(`${slug} 경로 참조`); + } +} + +/* ── 3. 한글 프로젝트 필수 조판 규칙 ── */ +for (const slug of showcases) { + const stylesDir = path.join(workRoot, slug, "styles"); + if (!fs.existsSync(stylesDir)) continue; + const all = fs + .readdirSync(stylesDir) + .filter((f) => f.endsWith(".css")) + .map((f) => fs.readFileSync(path.join(stylesDir, f), "utf8")) + .join("\n"); + + if (/word-break:\s*keep-all/.test(all)) ok(`${slug} keep-all`); + else fail(`${slug} keep-all`, "한글 조판 필수 규칙 word-break: keep-all 이 없다"); + + if (/Pretendard|Noto Sans KR|본고딕|MaruBuri|마루부리/.test(all)) ok(`${slug} 한글 폰트 지정`); + else fail(`${slug} 한글 폰트 지정`, "한글 폰트를 명시하지 않아 시스템 기본으로 떨어진다"); +} + +/* ── 4. 슬롭 지문 정적 grep ── */ +const slopPatterns = [ + [/#6366f1|#818cf8|#a855f7|#8b5cf6|#c4b5fd|#a78bfa|#22d3ee/i, "보라·인디고·네온 시안 계열 하드코딩"], + [/text-transform:\s*uppercase/i, "전체 대문자 라벨"], + [/font-family:[^;]*\bInter\b/i, "Inter 를 이유 없이"], + [/100vh/i, "100vh (모바일 주소창 — 100dvh 여야 한다)"], +]; + +for (const slug of showcases) { + const dir = path.join(workRoot, slug); + const hits = []; + const walk = (d) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(p); + continue; + } + if (!/\.(html|css|js)$/.test(entry.name)) continue; + const text = stripComments(fs.readFileSync(p, "utf8")); + for (const [re, label] of slopPatterns) { + const m = text.match(re); + if (m) hits.push(`${path.relative(root, p)} ${label}`); + } + } + }; + walk(dir); + + if (hits.length) fail(`${slug} 슬롭 지문`, hits.slice(0, 5).join(", ")); + else ok(`${slug} 슬롭 지문`); +} + +/* ── 5. JS 스크롤 리스너 금지 (하드 게이트 10) ── */ +for (const slug of showcases) { + const dir = path.join(workRoot, slug); + const hits = []; + const walk = (d) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(p); + continue; + } + if (!/\.(html|js)$/.test(entry.name)) continue; + const text = fs.readFileSync(p, "utf8"); + if (/addEventListener\(\s*["']scroll["']/.test(text)) { + hits.push(path.relative(root, p)); + } + } + }; + walk(dir); + if (hits.length) fail(`${slug} 스크롤 리스너`, hits.join(", ")); + else ok(`${slug} 스크롤 리스너`); +} + +console.log(`무결성 검사 — 쇼케이스 ${showcases.length}종`); +for (const f of failures) console.log(" " + f); + +// verify.mjs 는 출력에서 /PASS\s./ 와 /FAIL\s./ 를 세어 요약을 만든다. +// 요약 줄에 'FAIL 0' 이라고 적으면 그것까지 실패로 집계돼 리포트가 거짓말을 한다. +// 그래서 통과일 때는 FAIL 이라는 낱말을 쓰지 않는다. +if (failures.length) { + console.log(`✗ 무결성 ${failures.length}건 실패 (검사 ${pass + failures.length}개)`); +} else { + console.log(`✓ PASS ${pass}개 검사 전부 통과`); +} +process.exit(failures.length ? 1 : 0); diff --git a/apps/site/tools/showcase-audit.mjs b/apps/site/tools/showcase-audit.mjs new file mode 100644 index 0000000..284c3be --- /dev/null +++ b/apps/site/tools/showcase-audit.mjs @@ -0,0 +1,241 @@ +// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다. +// +// 검사 항목 +// 1. 가로 스크롤 (하드 게이트 5) +// 2. 뷰포트를 넘는 요소 (오버플로) +// 3. 버튼·내비 라벨 2줄 접힘 (하드 게이트 6) +// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다 +// 5. 왼쪽 인셋 최소값 (가장자리에 붙는 제목) +// 6. h1 개수 · 히어로 헤드라인 줄 수 +// 7. 콘솔 에러 · 깨진 이미지 +// +// 사용: node tools/showcase-audit.mjs [slug ...] +// 인자가 없으면 public/work 아래 전부를 돈다. + +import puppeteer from "puppeteer-core"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const workRoot = path.join(root, "public", "work"); + +const CHROME_CANDIDATES = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + process.env.CHROME_PATH, +].filter(Boolean); + +const chrome = CHROME_CANDIDATES.find((p) => fs.existsSync(p)); +if (!chrome) { + console.error("크롬을 찾지 못했다. CHROME_PATH 환경변수로 지정해라."); + process.exit(2); +} + +const WIDTHS = [320, 390, 768, 1440]; + +const args = process.argv.slice(2); +const slugs = args.length + ? args + : fs + .readdirSync(workRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + +/** 한 쇼케이스가 가진 모든 index.html (하위 상세 페이지 포함) */ +function pagesOf(slug) { + const out = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name === "index.html") out.push(p); + } + }; + walk(path.join(workRoot, slug)); + return out; +} + +/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */ +const PROBE = `() => { + const lum = (c) => { + const m = (c || '').match(/[\\d.]+/g); + if (!m) return 1; + const [r, g, b] = m.slice(0, 3).map(Number).map((v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + // 반투명 배경을 만나면 뒤 배경과 합성한다. 불투명으로 취급하면 + // 대비가 실제보다 나쁘게(혹은 좋게) 나온다 — 측정기가 틀리면 판정이 무의미하다 + const bgOf = (el) => { + let n = el, acc = null; + while (n && n.nodeType === 1) { + const c = getComputedStyle(n).backgroundColor; + const m = (c || '').match(/[\\d.]+/g); + if (m) { + const a = m.length > 3 ? Number(m[3]) : 1; + if (a > 0.001) { + const rgb = m.slice(0, 3).map(Number); + acc = acc === null ? { rgb, a } : { rgb: acc.rgb.map((v, i) => v * acc.a + rgb[i] * (1 - acc.a)), a: 1 }; + if (acc.a >= 0.999) return 'rgb(' + acc.rgb.map(Math.round).join(',') + ')'; + } + } + n = n.parentElement; + } + return acc ? 'rgb(' + acc.rgb.map(Math.round).join(',') + ')' : 'rgb(255,255,255)'; + }; + // 스크린리더 전용 텍스트는 눈에 보이지 않으므로 대비 판정 대상이 아니다. + // 1px 상자 + clip 은 sr-only 의 표준 구현이고, 실제 본문이 이 모양일 수는 없다. + const srOnly = (el) => { + const cs = getComputedStyle(el); + const r = el.getBoundingClientRect(); + const clipped = (cs.clipPath && cs.clipPath !== 'none') || (cs.clip && cs.clip !== 'auto'); + return clipped && r.width <= 2 && r.height <= 2; + }; + + const visible = (el) => { + const cs = getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false; + if (srOnly(el)) return false; + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + + // 가로로 스크롤되는 상자 안의 넓은 표는 결함이 아니라 처방이다. + // 그 상자가 실제로 스크롤될 때만 면제한다 — 그냥 넘치는 것과 구별해야 한다 + const inScroller = (el) => { + let n = el.parentElement; + while (n && n !== document.body) { + const ox = getComputedStyle(n).overflowX; + if ((ox === 'auto' || ox === 'scroll') && n.scrollWidth > n.clientWidth + 1) return true; + n = n.parentElement; + } + return false; + }; + + const contrast = []; + document.querySelectorAll('p,span,a,li,dt,dd,h1,h2,h3,h4,td,th,label,output,figcaption,kbd,caption,button,strong,em,summary').forEach((el) => { + const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); + if (!own || !visible(el)) return; + const cs = getComputedStyle(el); + const size = parseFloat(cs.fontSize); + const bold = Number(cs.fontWeight) >= 700; + const large = size >= 24 || (size >= 18.66 && bold); + const a = lum(cs.color), b = lum(bgOf(el)); + const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + const need = large ? 3 : 4.5; + if (ratio < need - 0.005) { + contrast.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), size: +size.toFixed(1), ratio: +ratio.toFixed(2), need, text: el.textContent.trim().slice(0, 30) }); + } + }); + + const overflow = []; + document.querySelectorAll('body *').forEach((el) => { + if (!visible(el) || inScroller(el)) return; + const r = el.getBoundingClientRect(); + if (r.right > window.innerWidth + 1.5) overflow.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), right: Math.round(r.right) }); + }); + + const wrapped = []; + document.querySelectorAll('nav a, .btn, button, [role=button]').forEach((el) => { + if (!visible(el) || !el.textContent.trim()) return; + const cs = getComputedStyle(el); + const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25; + const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth); + if (el.getBoundingClientRect().height > lh * 1.6 + pad) wrapped.push(el.textContent.trim().slice(0, 26)); + }); + + const insets = [...document.querySelectorAll('h1,h2,h3,p,li')] + .filter((e) => e.textContent.trim() && visible(e)) + .map((e) => Math.round(e.getBoundingClientRect().left)); + + const h1 = document.querySelectorAll('h1'); + const firstH1 = h1[0]; + let h1Lines = 0; + if (firstH1) { + const cs = getComputedStyle(firstH1); + const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2; + h1Lines = Math.round(firstH1.getBoundingClientRect().height / lh); + } + + const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.currentSrc || i.src); + + return { + docWidth: document.documentElement.scrollWidth, + winWidth: window.innerWidth, + horizScroll: document.documentElement.scrollWidth > window.innerWidth + 1, + contrast, overflow, wrapped, + minInset: insets.length ? Math.min(...insets) : null, + h1Count: h1.length, + h1Lines, + brokenImages, + }; +}`; + +const browser = await puppeteer.launch({ + executablePath: chrome, + headless: "new", + // WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다. + // swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다 + args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"], +}); + +let totalFail = 0; +const lines = []; + +for (const slug of slugs) { + for (const file of pagesOf(slug)) { + const rel = path.relative(workRoot, file).replace(/\\/g, "/"); + for (const width of WIDTHS) { + const page = await browser.newPage(); + const consoleErrors = []; + page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120))); + page.on("console", (m) => { + if (m.type() === "error") consoleErrors.push(m.text().slice(0, 120)); + }); + await page.setViewport({ width, height: 900 }); + await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" }); + await page.evaluate(() => document.fonts.ready); + await new Promise((r) => setTimeout(r, 260)); + + // 문자열을 넘기면 puppeteer 는 그것을 '식'으로 평가한다. + // 화살표 함수만 넘기면 함수 객체가 그대로 직렬화돼 빈 객체가 돌아온다 — 즉시호출로 감싼다 + const r = await page.evaluate(`(${PROBE})()`); + await page.close(); + + const problems = []; + if (r.horizScroll) problems.push(`가로스크롤 ${r.docWidth}>${r.winWidth}`); + if (r.overflow.length) problems.push(`오버플로 ${r.overflow.length} (${r.overflow.slice(0, 2).map((o) => o.tag + "." + o.cls).join(", ")})`); + if (r.wrapped.length) problems.push(`컨트롤 줄바꿈 ${r.wrapped.length} (${r.wrapped.slice(0, 2).join(", ")})`); + if (r.contrast.length) problems.push(`대비미달 ${r.contrast.length} (최악 ${Math.min(...r.contrast.map((c) => c.ratio))}:1 — ${r.contrast[0].cls || r.contrast[0].tag})`); + if (r.minInset !== null && r.minInset < 12) problems.push(`왼쪽 인셋 ${r.minInset}px`); + if (r.h1Count !== 1) problems.push(`h1 ${r.h1Count}개`); + if (r.h1Lines > 2) problems.push(`히어로 헤드라인 ${r.h1Lines}줄`); + if (r.brokenImages.length) problems.push(`깨진 이미지 ${r.brokenImages.length}`); + if (consoleErrors.length) problems.push(`콘솔에러 ${consoleErrors.length} (${consoleErrors[0]})`); + + if (problems.length) { + totalFail++; + lines.push(`FAIL ${rel} @${width} — ${problems.join(" · ")}`); + if (r.contrast.length) { + for (const c of r.contrast.slice(0, 4)) { + lines.push(` ↳ ${c.ratio}:1 (${c.need} 필요) ${c.size}px ${c.tag}.${c.cls} "${c.text}"`); + } + } + } else { + lines.push(`PASS ${rel} @${width}`); + } + } + } +} + +await browser.close(); + +for (const l of lines) console.log(l); +const passCount = lines.filter((l) => l.startsWith("PASS")).length; +console.log(`\n${totalFail ? "✗" : "✓"} 실렌더 감사 — PASS ${passCount} / FAIL ${totalFail}`); +process.exit(totalFail ? 1 : 0); diff --git a/apps/site/tools/verify.mjs b/apps/site/tools/verify.mjs index 9020384..53269de 100644 --- a/apps/site/tools/verify.mjs +++ b/apps/site/tools/verify.mjs @@ -17,7 +17,13 @@ const steps = [ }, { layer: "L0-정식/html-validate", - cmd: () => spawnSync(npx, ["html-validate", "public/work/gaon-lms/index.html", "public/work/dure-enrollment/index.html"], { cwd: root, encoding: "utf8", shell: true }), + cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html"], { cwd: root, encoding: "utf8", shell: true }), + }, + { + // 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도 + // 잡지 못한다. 실측 사고: --dur-fast 미정의로 transition 선언 11개가 통째로 무효였다. + layer: "L0-무결성/변수·경로·조판", + cmd: () => spawnSync(process.execPath, ["tools/integrity.mjs"], { cwd: root, encoding: "utf8" }), }, { layer: "L1-단위/계산로직", cmd: () => spawnSync(process.execPath, ["tools/unit/calc.test.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L2-기능/감사", cmd: () => spawnSync(process.execPath, ["tools/audit.mjs"], { cwd: root, encoding: "utf8" }) },