From 123d9fc5a8cbfe221576e85f2ad7bf6637f57cca Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 24 Aug 2026 12:50:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(showcase):=20=EB=AF=B8=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?CSS=20=EB=B3=80=EC=88=98=C2=B7=ED=95=9C=EA=B8=80=20=EC=A1=B0?= =?UTF-8?q?=ED=8C=90=C2=B7=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B2=B0=ED=95=A8?= =?UTF-8?q?=20=EC=88=98=EB=A6=AC=20=EB=B0=8F=20=EB=AC=B4=EA=B2=B0=EC=84=B1?= =?UTF-8?q?=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EB=91=90=20=EA=B2=B9=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 없다"를 잡지 못한다. 미정의 변수는 선언 전체를 무효로 만드는데 콘솔 에러가 없어 조용히 남는다. 검사기를 심자마자 v0.9.1 의 실제 결함이 나왔다. 게이트 신설 - tools/integrity.mjs — L0 무결성. CSS 커스텀 프로퍼티 참조, 상대경로, 한글 조판 필수 규칙(keep-all·한글 폰트 지정), 슬롭 지문, 스크롤 리스너를 검사. verify.mjs 체인에 편입해 실패 시 배포를 막는다. 폴백이 있는 var(--x, 기본값) 은 선택적 주입이므로 미정의로 세지 않는다. - tools/showcase-audit.mjs — 320/390/768/1440 실렌더 감사. 가로 스크롤·오버플로· 컨트롤 줄바꿈·대비 AA·왼쪽 인셋·h1 개수·히어로 줄 수·깨진 이미지·콘솔 에러. 반투명 배경을 합성해 대비를 재고, WebGL 페이지를 위해 swiftshader 로 렌더한다. - html-validate 대상을 두 페이지에서 public/work 전체로 넓혔다. 수리한 결함 - 미정의 변수 64건: --dur-fast·--radius-xs 가 정의된 적이 없어 cask-archive, cheongon-care, materia-interior 의 transition 선언이 통째로 무효였다. 새 토큰을 만들지 않고 기존 --dur-quick·--radius 로 매핑했다 — 토큰을 늘리면 radius 어휘가 4종이 되어 하드 게이트 3에 걸린다. - 한글 조판: lumina-optics·pulsegate·synapse-bci 가 word-break: keep-all 없이 IBM Plex Sans 만 지정해 한글이 시스템 기본으로 떨어지고 있었다. 같은 계열의 IBM Plex Sans KR 을 스택에 넣고 keep-all 을 추가했다. - 슬롭 지문 19건: text-transform: uppercase 로 만든 영문 대문자 eyebrow 를 한글 문장으로 바꾸고, 대문자용으로 잡아둔 넓은 자간과 모노 서체도 되돌렸다. - 하드 게이트 9: materia-interior/hannam-duplex.html 의 100vh → 100dvh. 이 파일은 어디서도 참조되지 않으면서 인라인 hex 까지 갖고 있어 토큰 기반으로 다시 썼다. - 접근성 11건: label 하나가 output 과 input 둘에 걸리던 것 5건(font-foundry, lumina-optics), dl > div > div > dt 4건(muwol-coffee), div 에 붙은 aria-label 2건(overtone-sound, pulsegate). 측정기 자체의 오류 둘도 고쳤다 — 눈에 보이지 않는 .sr-only 를 대비 실패로 잡던 것과, overflow-x: auto 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면 통과도 실패도 의미가 없다. --- .gitignore | 6 + .../work/cask-archive/docent/index.html | 21 +- .../public/work/cask-archive/styles/main.css | 44 ++-- .../site/public/work/cheongon-care/index.html | 4 +- .../work/cheongon-care/protocol/index.html | 15 +- .../public/work/cheongon-care/styles/main.css | 41 ++- apps/site/public/work/font-foundry/index.html | 6 +- .../public/work/font-foundry/styles/main.css | 5 +- .../site/public/work/lumina-optics/index.html | 6 +- .../public/work/lumina-optics/styles/main.css | 7 +- .../work/lumina-optics/styles/tokens.css | 2 +- .../work/materia-interior/hannam-duplex.html | 33 ++- .../public/work/materia-interior/index.html | 4 +- .../projects/hannam-duplex/index.html | 24 +- .../work/materia-interior/styles/main.css | 37 ++- apps/site/public/work/muwol-coffee/index.html | 12 +- .../public/work/muwol-coffee/styles/main.css | 23 +- .../public/work/overtone-sound/index.html | 2 +- apps/site/public/work/pulsegate/index.html | 4 +- .../public/work/pulsegate/styles/main.css | 2 + .../public/work/pulsegate/styles/tokens.css | 2 +- apps/site/public/work/synapse-bci/index.html | 2 +- .../public/work/synapse-bci/styles/main.css | 2 + .../public/work/synapse-bci/styles/tokens.css | 2 +- .../current/gaon-lms-dashboard-390.png | Bin 49398 -> 49397 bytes apps/site/tools/integrity.mjs | 236 +++++++++++++++++ apps/site/tools/showcase-audit.mjs | 241 ++++++++++++++++++ apps/site/tools/verify.mjs | 8 +- 28 files changed, 658 insertions(+), 133 deletions(-) create mode 100644 apps/site/tools/integrity.mjs create mode 100644 apps/site/tools/showcase-audit.mjs 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 @@ =|&n$yYoncNDbW}EzJl65+jYY(gH(w_fW%o z{?A&^hxa*a&4<0u-s_xm@9X~E*WPmxjCmf6S?iUJYkiu;(KYJOlN6NQ0SKS|V--N- z2c@eQD!P;h{cHXHwQzh2Z`}D>?9O%dOVkUp!0kt25o@s8`h**MUoNiLCr?(MSxr?3 z=Zc466T7Z!e>1nZo^lR49$m-B{_54hK}tn~ZB2wY+S>Z(WHl9+?Y3S+#&0wj0N?k} z@SaDdPdFo6$uz=7zU!%Cpn-pRzz7m%%@g-ha@TWrDvnaHT_z@#QqV67c6Eb*!Qq?7 z6ffketE)b}Nn-TZoG3!L!Z9oC<9NbbRT-1bQMKDLCeXE}aXb~0QSYUl#Ka4O( zb@Nq_PiDV%n60H%6uxC8ovAR^yzBwIvIM)B5evnogfY)w=@zHY73`8vQg!vZ_W_G- z(Z#*JuH%`hH3RJuMcfKU)Q!%I5iG3>B6kcIlrP}x>@#4f!goH)SVh$Pmb30 zyXdI;!d+eGdVjpc{*zQf%%UT@WttWdc;}wht}>u4An+FWzM;=9%C0C8o;WYpxEGbj zi(goNzcTx@(U+WnVi0#*37VTrGr%=im(Mg^&Pcg*v-Ugb2IE}8{P1MH50+`pJ{JdtASB3bcQk7IyKa~A zsw->1-Jq<8i{rE8TlbB_P;7$!q)mhF>z){LN(O+p%5#Pd6m@z$}{;{`)7=;$7MB_ zDvV#B5X?c!*CAUH2XiqP_Z@?qKs`+)1_lv^g0!Soz+=LSRY)8qgwM4O=H>Y0@qMdh ze^@WD&d%W+aC-F&=H)x1H#fzm7vv}C-QDOU0SH@-vrCVQ^(u268h#C|Nno-fwo(7U zn3=Nx7Y{iEWwQUzQ(4siVrU@{10GHEQP^cEzrCGCw5c;3W{oA7{DoO5^OVC)3oP=7EjCjTFgb)ZYaA2XjpW?a z{+wXSIBd}MLz&Iw_emQn4|fh`rh{3S60R+n+n&RjlY1f<^(Sn5?pl>AbCxcqj=&IC z7`kY`$`OeT_#OSL3EF-vwUVVqn}^fNVKg(VZc-PjZ-I$v|7MW&(D()ia2vYRp8xo) z6*quTv*M>c+XeLpXtGSJ-}%2DnLiXY2JojzG`#CXy^tic-6=;D8`I-Zy*k70b89s_ z73%VA6Cz;Au_qKs;@_X|FD zTyB$@UXE=cotnUNUy>veOo>Q3>&5F~)`r85b_<&@lI;-uxLUqUa@#NxM?BRppY&AC zH8gB2r8WLeqrX-T4H2zD5Q>JkX|;ev;%Z*w%dKK3=9!(1^yk(T1xz}@EkJrKv6GYm z5!rx!`9jp|&OWCwjJ7rvqOdSJ1qL_zOkuot35Ef7R8S@c@^5MWw0r;CFfS|Yb7N~p zO$ae5NON~f_q~@86rU3+HJs<2 zgMW*adgG`ITOke(d!=f5-P8y&HVsQ;Yx7_#m+e$#G4%`s+@CoMMGc!KpC9wwwcciy$KE{1< zz5-73^6T(7&sdZ>HM(cJx<1g~{R$3s-yB(N^(`zcWK=u8yyVd;BU+4VYip~nKIn_5 z)sr2svxp?;$z9#t_C;+B8)%l?CMP8P0v`cL?Le_;2RfyC`bDv^vF&~?9!yp3MiF;W z^4k?99Bgm<-dyYl-CcLRBrTN)K)H;U3}*ttm0TqyC0=d=sa!XGv@c>*ryx`Xx2a+%G z7={CQY7`IV;K1d9Sf_)A21)$JR*c{62O+AUZ_x@YUa}VU_NRNZuvkjLjqd*@;P>Z1 ze^~U5fYpw{fvM=d({oPUC{r)M&Fy3swn@af8Cs%UW4w<#UM(mnkiR?=wX?B_q7xjn z*_kMh?G?W@r8R1DLH8#zu2?YQO1SH)YHP9|KhNbK6>InLA06Ybk3|elTMats`*~E#wI`gUAi?;&QaIfC?`} zo^W<1!_!NV4kaC55% z!)6$eGl6y*Qpm?5D@Jo4NV7>)5bJ zIZlJdfQd3b5$}U9(b2?zF&{qsG>W#LZ#tfl`A+%!{Zne;we9WSrS!50;P&;xNVVy5 zXWZR&b7Xky9;g7itoAT|axj{%=p^nGLOVtwFljw>_f#Bd^P9=Hy zC~p^+CAk*sQKai?ubzPc<%<_zsot%0hU2qSi-q)`%ZsI2Iyki2j?3cXpMsoX=MnQc z8oRq81mlwrvOqPD*KDOXmNJd=%6cqcQuGl-BlzK!in3)ybaAm&t|-LNFX3QgqgMwG zhfhyV-l2{eb-p}#@C|rLL6Mo9T$DgVO??MSP}eI#wL8AEQ(Qz?_(bOE(?_nNS%S`N z8b$r!C$gnHNP-}#!SO3{BQ_LBmj`(i>>A9gS!XVT|D3a*94x<+6BAgCNuBIO zbY;4r9!UR!8SQg(L5=d@EId9x-(Lhwb#-6lg-c?Ezh_s^$M``A1IZdwXtB(!^t>zo z&FgE5$*J*i!MAS*2FjjG%137ZTfyGNT+e86zzt#?dh0Z=D+#3#aQK{eqMvk}`T}4x z`gs;hIUS~#!et{Aq@+|qNc%85Cx?%c^o&hFpu~Am{OWX54sAbO4Qj@zzx!o-9 zW@dOpQ0V@SL*_Q$(?&>@FvTZXU`97xRrS+RmQQF}I~wkRQapO5qO7bJ5n}TZG(s*v zq!f!4wU5YY=G7!rGK7wClnH4>XBOIk!PF{K?v9rEHhzI z%Fey{ly3o`tyfRk5hJDaV5?`aSVr9I<9w7;#Y6)RXI}A5&QYG;ItPO+08rZO8CdQ(tmxtnpgolM8 zfhJeh*RM|w7H)ELSv3LyA$E4Z%LB~*p{TDBRPf4QT^1I_ie!T+9Hj~yAm^UYik~cX z-0dJ%2cI7-B7lvdSz^WrN=nL8QWf!-L<&AWK4arF3k!?g=^97J92_MMqXLjust9=C8eRM<>6E@_e{3}2-dlq$bbGbdv!^x>UtK1TJ6PLFb zlb@fTee<#we%Y|LRzwR>;Zs`A*7Jjbs?jKhHVJYBV-xJW_GPZ~+AsP1S;1D8>+dwozRc`=_u ztG~c*@_V|F@5#hO`8X6x0$i=9J*ED)yS<$w0R)X?zpJix>gRPv&YSmqsjpnw)?D=3 z|M#=ya(5P%CUkecI}O@a&}d|ec%#3`;JdA)J!^WTehX+6D{hSDk;AM8l12TeMCg_* z%+0@ClRSSO_oSd8&!jy-yFFkQjDMc^evjKD-b5qOj~2ik-~;>|Cbmn> z7O#D9qu9w98t4l8Ru&bCQzT~vq^gs-+_>$h^=NKmeLV?8GW_=D5cX*}6VJYoyS}iRSv!yD$E3?*7m1)oFJeAxV~iW87cp-yN_T z%(i%|30y1U+7eO<+}0pIc^}Lx$jMpXTvUOm%YI|cEuFOfK5RxbNp5&$RXeiRRD)W~ zZ|7v)-euO%kV>a=rZ(W}XB_ohXQTN^yn`M$gSa?NFAs&;Kr&lbJz2RD$quj#p1?%Q z5xsu<_AUSL(m~xDLLm{bO2y(n_#OY_&Wp%-(LOE0qbS#PvsGL+b=~fWZ|GT-y=g_GpeEJkL z`oxv&mXvQkZI!6Z0bt^NERr`%ZFzsC|6?l;Ttv{2a?IVif^U~w-~eYD|IiWgh*b^! zQRNlsV|z6&Zn(^fBtcPp zU0q^I%KFVE?NZ`$k#VaJH8Jtks02s)p6rWgcYTA+?Wy-wjC*W2IR5ljdo#;6j?x9; zUpgS{T4i*DS4$eVr=u&5bB)xZKJQ;FzNP%*3zCTuHzWaDcuZcF2#cOzOIzW8{NQ_n z7l2U{u`Med-PlO=XO`%9X_tp&2qhlfv0UN|S%?z!aPt>V2YMy>`f0UqtQ9rpqaInm zt9$GBQT2^rkK0k#-AYeILc*wkfUlupc3Ah)5|P0i)zr&EMLMzZ()-zh51<{*!ixqe zNl0~+&2O)NpkCbUo`$*>*OB+|<1Jn!zOyW6l8hfXOixL_%^L~L<~kMM7kl=s z;c~?HB@L5tQE8!nP2X8|nWFRJwexH}KD67KULo+>gTruU8I%pBfJ23LgS2 znAUs@46hj&9*%<{CcDHwKihtDE9QR@68_{A zpUySzR~t5)BU=UDov|3V2Z)J^wz{rfOGP?1St(EHn23o}($WGOU(-N$Ri!_vI|VI< zV%^~Somq9e(OXTajOq!B(nj_>SZpQw5c3-HkyNY+DO{hd4}Kxr6%Tw2+IQDPhHw)^ zkpC^}WzkUj@w4u&ZB0Nz^Z>;sy8AA?*`566`Wg?WmM7k)Mn4KRuexI?;lQmc=itX* zU+=a{7sK?TI1e&;DM?9()>!*T`B;(CTYn-53^E+fehM4G= zf_bw;ECBVwZ=%sL=DRjW&@IY#w!Wb@ltQ4A!&1n5gi>Il>|-d1ckk8SoUX`ti=%dV@ zN9%^!(qM0Zv0c(*{4`N*q@*3AZ%z!S4hj9Br;3TzFIHTqrrIOMS!3fe5b$(&?;(69 z`Y0yh!7S<*LJv<*;bnYcB0ZYnUnh?D#<-L)kko>W!Z(Osw^UY9DN)NSO3*EoCA-yi zdXR!sBjNUp7idDTm^z!7u5^D~N&Z{BaAYwnDIia>B4?|c9Qw-W=UPvU&NA$5cRIAx zG_&+n@8Gv};8Mcab06-$xEzSQ!)(2RDi!2SX+kkH>>}t#4Sg<`YNbxC4;!VwctI^L zkWu$|&{nHlWBFoV5sB2v6+7k7Dq8{%KrtVRL#p)gwrk^wa(^w7Fn?K((|O<~2!dyM zGWx}jY)GYU+`1*vsO$``6U81Q3uPU*(`as}vcQs~KO^I;JQuAv7LAD4<4@x8Ue?Td z>x-&3ddfJTt2cH<@Qk^~ojnNafP z3o=Eo3lkmxr-u>l=`yaOP55GvhWyZWjpDbqJa|obM1(6q;1||N-u}b;&4!<1jj#+9l+7Zn_%;khG{jo>?&Hr3gRW zr3kWxV~uP><6xYT#hFLv%8n$_7d&NUihTN8-&$Q!n_H$rLXqd>Jpfhr18zT-D2|n9 zqc@ZkHkW!=n_wJMJ2RkYhfYgJ>= zj>wyGmPH$P`E_cnEqAaqPg{U^=)`5ff9Kx23mQf=nwCyj^pP3kkDBF3yxb6({?88m zL-o(0MNd8?dDjDEspb&%S0ViroLpQP^Nc|Lw? z%5-RzNwFT$I~v9I*<=!Z^v8*VBjXHr-~MQNB-?c;bwppm1Dcei_Z-^N;-b`GYvMAi zHL`&l7RULvK)a$u@BZ5PIs;S;)s9w`PSY}oDRb!v=pfdIAwCk|n9*JtZK`w0)gSTT+Q@`7R?}(7XB?s}#E3*ms6d4GHa@2F~Z3Hg}cFpGOuN0H4B6}6)oGawx`d5I*!toOz~ug}wDnInF_8OSDUpf`|cX+sSC>AIQQTNL{=cn|0}G;EnZ z6G`pDZEtkHiEQMbw*!2~f@7%+_`&T6q~LROf;&Cz`GLIt_o#Z)){(XxEvXnx=y! zBP`g4TkL9k@!c$68u>!uFHDJ;5v|^;o)1nClQ)&Z_=q<9QGPQN+3rBw&lEVx!u$}U z0dx6$nIJ-N(ZyqRpyENVz^6LmkG@~{uge2RBi`-dW4$y>6Xog6s0CJWZohlJx;~5? zPq`jlF%k=$k#G;HgpsMmKE=m})zw3-zVwY#-5;_f(7YUFlJmXvUDwlSU0jyo7>Ue+ zY`<$XC~sZi=}NNfpMOd2=+9AJShG8{Mb7$;RE*xm_>nlJk6-I*Zl`OP`$*Za!m*k2f&amLAYbck zlpZjt`n#wiCzXP%@4VLZL<`$`i>38S3?*>_4t;dn1b1?;{6E ze-9DF#&YaYWx`|=30niv#ep}p_pY!MvN7x~botf6zmJFRgbx0bKf+Dhnt%BA;N+N;p*) ztzmPIM-FA9XDSz7(97gCs=%PT+tt*zPv`gsAu!xrp|XrZ8!JrrLBSSj2uc5RltLzo z5Sg_}`*!QRuV!c!UJfAKX~)*HR=RO=4SpVFe^|`euNlc~u3i2W6q?22(|P=x4`nj% z2@&w{1LWy$E^^|7EqW+C+h^6W;bKl`>*Aum+|5;vSa)+h83Dz=(o$BX{3=sPlVTi^pT zeuI;${oVXqo_L%ypQpK_!IUWk>Cdxj!mD5on47e=PENl+Y->n5)Xp3~Tx75_r3hR+ zbuH+4qn{T3Pc;K_HlXx~R`$ri{Ym1bnlWsTzfg%e`_GnM+%#<;W5m(Ut`vfC&Pn7v zYqmW(#p%j7PDhNk??r7ke`b~@%-u9#cn!4ZOE#mzB1KB4^Wo(u0)3t9e}d7`7Ck3%uf8R==Nb?i2R!G7 zrJQrcOxYeHd(RDihrc{u+SwNw74Pow<@fFB>vCfyPi&R)KCey0OM&hSrVu(pKDmjJVu(3RUW%O! zC(=zhBsJt!pU{^QyZ-#&^wAM>A^B{zLSI-S-Ofi7Q}aXI#|n1GRtXxs5WvWxwWyIa z3JDrYq+VK9h6LJm`LN9h8CZ!k>nO2SJvp}33UrrFpTLrWpu^#v%7y5Q^5in6tIHEu zLtD?%d$0Bvgw@ia3S;ec!2Kt?>3!zEp>=jcM^2aiu+F-NK<{MCN6Rr1rSYIlRcr`+?E zg_BF7Df2=|JJH#c8)Js*`4M?`L{MWAq|*mBv0q|jzteZ-sqj%#NN8W!d-BVDn(yx% zj-10yEiC4S#62}Bec9n41q`g^mx34}2~7Xr&*=Yl{7;)4Gx!Sz1_s4{|DFGz3IB6H p-~k54{k=`utvxsj`@d0`7^D`)-z01tI=~(b1sPT8YAMr@{|8po5>o&G delta 8989 zcmZ9R1yCGc@aLmwfB*r4LvRTiELb3T2m}c(!QEX4cXuav2oT(1ahC;xLvV*>aa&+- zes%Z1y1J^}s(Cx}X5RGc{`RMPFa4ii`9H66%|hGlJq$g!%kE9|&FTVL2u|qPsGo|; z|5BoBQWdq)``$lkaTv7E_mK10#6#=<;CjR+MQ$us`p}s98MG6JwG@Q$7?vo;= zcwku58M9I~r_Ui@$Ib59%jZ(9iv$?hIDy<@`tHuoHjP|X(hALIa}gPTOdw0HA4>&o zz2y)1>{c+C#LZ?{8J++D3@T~{U#KM-i28Zi;$k%rO}d&dITB4O+`Dl9u)46cbncE7 zMWe2+PDz=oRc~qNda{;>jg3t~arh;bZLB7%s_JAYk)fy3+0ijT4x5zszOuvA?RXje z`@o!kC?PyR~`HKrZcy*o*sfvVHx(^WS+ z!_sPp<+|eSvKlx}=@&fjP}ou0-}I5*D@c3;`!X<3sROz{L}2RnGp=&DMQyTeVaC6+w*Oaesyx zQ>#=EqL?cp_Kxxy&c(qDK7gxgxe94-_FxP5d3!**cW~hHM+s04%@LZ{8K^i47EQ_V zu`kPyw6b0XgqkAQ`V^rW_8Z5S19tkzR`256o_TeCY2{-m2?YVQY36JFiZs&Rmy6R7Epl3B8N9*Vffl>Kos+pg z!d_hPHHDSg5Z-#sSoqid`K5`@&S@DJ9B(woTrYdX!Cz_U^ZV6M6vfqsKYII)^>Rt; zW3H?3r#DgvI;k=Q&acG+QMQK8-y1$`^hHLK@&Vs}t19X1BhPoCG7c{9v5kg@hKNI^ zmKxzdsB5gBc?QiBM%9xGHgwhCvjGz10)sP<-J6vOV79Q1y0plB%YbTH17P8q;i+$nL{FNSTvMr~P1<-Hpfb_-T_eS(71Y1V^`q3nOA)H2b*mt7A z1#$It`{KxP`86{bt2`vA|CZ&@!@aJK@Wi$LwYcckn(`p9{mQ*D5M%^pH@w7T8UU#i zW}c{pmsMtCS1v`{RhfQEK-|sF-+|qya5*AZ-f8utpR@Cwor|MqL#=a^~DJ9{-MT7ho05(&jpv(zE ziQqFWOTPT5lWJ1xPj+_rFQ}I@b+M@T1Spe zga=y|FCLR~l5+wY4dqo}p2O++njo>IFzWKkDV=%U*_g$_<% z?6p#vD@fZjQ-8D4?iKWy)85XGP5$wf!uc?m}1+4e}Q)trLN zRJ+&pN9VoiM!Vb$9;@**&T~*(FbPMA$|%HMYlcr}!v+TjhZ0L(QHoffZb)u8GfqQA zku}cy3DrgejFkkWq=@~$3=RW!k`Zsx`RtBsOum5kF*G!Mck!heK>A~oFCwp&q9}x0 zDmAK)fZ^maailSE-~BD7{MWHt=;Y-703}0cs5D0al`6H1EEg~DN}WYPtC#3Y03&Cv z+9(HHtMV;uGzc9(@{PmQ$;mevQTw&E<>qrRukG#a4XWHfJYW(q_wpIq*;SU6Ax<}? zQzo5{=GXfocW*pADIgx_vq?;%bhNbKBY>p17TT`RnyPP{T(SQC{u#%OtuBWj4SP?d zqDhzvy*xcvAU>`79gp@Xefv0+BFl9awLAJ$R*|KprP*0ECL@oh1MqoW4UM-nXyW0^ zw|Jj=La=tAxw}V4<#7z)-{NBWHUQ8L zpn7w2ywSu_>3Mxz?*@nP+38_!KD&g&V<nQkHnnj0pBXHU*AE(&>FS#S173pjui2A?^W z(8A`cYdtSB&`8ml;xb=i6240Vva_jNJaTm?={i^u2dCH)os@7LFw$A$d1A z#+C8W#j>CpM|57Ah%HOhaVzX*9xdQ?T+1dQG4VpP6OkRf(&%y;wrPcl;N5PXufepE^?ipDUSxg@xcHH*jCNw@1_i0t1tYl36vU#wvn1NI_uA z%CZKHS-9NnWOjAbF;}T?WotW*wjoGmrXj1Oq%;sq*>curwJaJ< zRYo1Y#|8%LQB;sGj()VlURQ0hiKaACQFkEt7#bN7q(FQhTI}@Q3J>@9OBcdAI)FeF zEJ`8wiJxK*CmEz*^@^GeXsoDrN zIJdewJZ`tsKae9VM-;gBxjwE44&F|n`|?)IJ5@$Efr<>wZ^R5*fE03QX;}R5y>Y+`TPxZGvaZDn%y~X+wP$EQp?=LR)RSMy&z|YU2R~-+$phb>Z z)L2^`w=IACpc=u$!`mPyBfFVAUZ{0jZgQBNokc-Gp#h|0n(WsD4-^5 z?H21MySwlACJTnY;7|xTBOe1ndx6Rj_Lo6Et;u1tpYF>e@dxwQivG!}KR@~6B(`-tmE-#mJyLW7-jKn9jggX4i*s1l-vECo6~k6s0;ZQnBc#Ko9t#Lp7>et}z+# z67x(FOvXGsJpQo4DvTFs%(Kl-48a#*e1lqpDndSD?(O4)xTbtdNZ4;?@LDVn;|=2z zSkDnCvm1LF?n4QE9D7kw5UKjhiwgh?E4qg3x8&<7>XL@0uDeejJ8Yx@t7zPy#ns{p zL$FBKO4?F#S0no(Q&lVVzEVGcD`ipt^INAGZ^T?KmCa|fpT|JW1d}9Yb!zIMC-jM# znb|1HOP`47=>4Te#mD>Gc;sUJA?Q}5jt5?{RaO+DzRRs{m7fG097sI)05opIjS!ns zevr5)^6^-`Qm>)D-ekc)J^jBVwr(MA#_6>)O6(rP%gYPiL)7uaOV)0st>SxGOh`H| zFJW;651j`mdr2y~6#|zk#SY zu5R$v8WWbZFDGnn@D(7O#^A;2L%d2ZB7%SgT=&*r^W?+j=16y@j9Vf$ogl(Gxn#(l zApx`M$;tf9eKV!FLMof?D>O8FhjLPA4Rld0{4AC-c$vpiuW zZes$_w3&i;A%-4baC(hXRb4IaQub|QI!=&|NxD;?QoWLALe4l)rSz7Z4sAaUMK=Gf zWkX*0df?B5`g$JlaVQD2@A#f`b5m0*-NJ51pNuu#-FZ%bh*tpNXBpj?k?r`)Y*>8N z0K0y|Pd_QCkb9W-JFJOd8b=1ra_MI}7{oxl+JBscc3*EjNrUY=rr*mj_2fSw^1tq_ zNwyaC%fjpf&&%*;$lwWp15Mc%Vlrq6v;jY;sq^@3m&2o?+PtnICA;COtCTSj5i9M8 zCtTd`3aAI(vw$0>QL5bMxIJufHAH{QTXemA3Y-*zibdr5jv?-@6IX>IV1mc_86SaoK6~LF6zfdK1jL z*fY*|0PN|BUhH#&=-vXBi6bI-`1q;p z`UWN@?~ngHS!0c%1VnSzY3PmVaz%VH1l@+xx%EKr(ylS)F;yA1u+#7C>q8gS)z#H` z&A=cnPsD7Tn+bu1M?~D64h-$=q!btLxE#&`Hz(91wu5pBuc@i2LFGHYJmkyCk*1eV zJGsAgnx`42#4+Q2e0=n|yTHW6RN?(=y9(Ldy?)2Y$hZsmq8!0|-U|qT)HKlDui0Qd zHlB?rQ!S-E9+Uei7T6-MCtI%D%0#uMlF9~q|Ni|C?Jg_Cy?_wWiz)|8Zqs{L&|gDw z;DY@8)(bTzU~vytEaRP!-M>#{l?feg&j_~p_Am$>e`(b-(<|f%@wgn+6&CjD``#9T zGVSRJc~MRvxs zJIp5ovTn9t*gk#Jp<0@7d%oLeoz6uE&AG%zyGct+%ZCI72h)5u-`fQ(oy@L(b+Lb5 zqUcqv^Hjd@8osKS{V~~X`ft&fIoOh0r#QeMJNz*HM;Fl_oq00&Lw^}0TiL|^HolSH z-^=V}f2&u5`J$wvygcapH)`Y~qFl4qv|FpXs_KWPOo0^E=_WY=T4$k~8zsk&Pe_Ol zqNVkkQowO~6+k9$dTvI?g!tpDRV>cw)g8fLtgNG8#y;KXE6WzdVcZQRUx)bs#NUGp zjMf8DV+mNz=L%&57wfG$<+M6{xkuJJr$L#I&qh+}=C;OUb7% zVp|hFUKslL@bAH)_H<6ua5sEMnmw#7eS #$@73%X5B zPA))v4nd{c9ZkoaG!}RbQ1e2!lxlC>XYx4HRHb8cTD)&bba;t~1_)SqS@j`*j0PI2 zD$?$0v{NF(Vq-&3Bc;-~OopqhB@YsUJlzpjHXFD>uduMbe&ufRIvRaGJ`N^wBSQ%G z6ei35Y=t&Hg96^&eTa03U;Kmxd!lip4M#?fh)+O?+FQ3;^~xMT-uENeapB{Ga?U&# zGSu;y=NHF#Ls(6{efLDi!~XGq%I~Z0;pD@>z|Wv0z9_?3=#L_9vG{uh2HqPc<+N?K zZxF)wcf0-3q+qQ&>MMr13oh>nIeBxUPX>Ci54i44fU7kX&o9{>dX6e&HkOehsA_S%Ob3BL3Gj4v zBY}sT&9_p<*qY3;69s32rgZekl5;Avkv2syXs-mU-AOM5HJoUFe{bcGQ<&j5*kF0o zZa4~{J=K&^&K3EGLFC%xeY+TfwPZQ-d*zRAWEPA3E>u4%EDZc~UVujKYZnvtxqAgb zbJ~AU_q2%xeq=QZ5WGB?A>EtQ2Oa=shzgk4L|qQ<8*ExZz)0h({EFQk4#qGJ4vyV& z6J)}G)Rs8$az;HC+CMOmE3j!|VWC;3#*^T8+qvJuBO97e$1KA!>MyU1305OZE~-=d#6jfV5# zde#~AsL_+QACHCMKIg0kknsi5ik!FesQ)#edKc?6?N%WJ=VsN+N~}zq0>C5Q1)8Z6 zsB4_sMHs(Tw1Kx@W$b3l*ZrQhe`}@q=*$XgvxhxcFosan+brc<&O}g(`f4>;zd!mJ zf^~>NJf3|Xg2iJwy>@?l{;$bl_hfxjqewnonRV9>Z^U@J$`iIYv(}W&?|>~DEM&h- z)!P(~5s*yd=M&UX6$_Rd9&+Y;X2IyJ;wbZSLQZPpz0{xGOG^aodgUOeu(6G&5&EV{z9Tfq2=%{Z7Ql9&4I{3%np2+1PtoYL z30+(Jmh#uHL#;#$hD{-6)X|Zc~9E>T$a9{f717?sAPOZD-#*#p_IylApHjWzI;oQ3(N_c7t^S ze(2076X-2@?UXR9FtbSfLjqR4;r<*&Mpi)Fw7lDq1ned@`Lrf9UMHJD@4-mEIsS+6 z^NamnrY8RJh$e{Pq){T=uzB;l-|`r!J?<{=P~Mjiu%s9X5ck&N@&;t0Ai{>E$|IL* zcc~Jw+?Q4cWq6fGLUH^DVzL9$p#}dmWmDvo^^BbAuFtP{p8k({ijq#n#p}g9|k~M6`mhk@1A8JQgH(rDAOG0W#Wj7*jL!B;9dbm=~FnL^(r8?`mI8G=wxeCyXW>W78J7{}} zN~bB5`MHZ}jUkKuf-zN~&}2AC6(S~MEI<~FhPxrXtdIXG%3`fkgjJAg;607l3EFm| zRdhu~y}OdVJ>wDmt+LgPEn~a6+h(wUblDWBEf^lb%}I!hu%V@q?~n`$V+vYX;-S3H zHM7J?%E@0Q4`Qbxg@8EPq!mgasT9wdxuoMZVd@rdidIC#7q&PCf+qRLx*c}PJRhu6 zYx?UschcnoX%SRx_r-Y)urt9Ix$6G2v5_0^Ev9AuTvblh8xQv?+2hN3l$XIA0fXY> zS*I-^GIk(Q!>|>_=FwnQHwFPEKXAFINV#gugK3$1CbqHx>)@g^f;SOVH7H-ce4%el z{_*1U%{upUgJ+R(VuM-9#-*ETv$JA~(nMlb4(~E%ZV=LG-!y_oHG;Pp--ydJM}duw z4R|nSs;D=b49_`O{RDk?d~Ggle8zY5C5iD5J*l12cDfVceeKf~0dz&Nx}{+`dSP({ z$$dSy8Ch+Swi90V)|PQf@YXTgva-!0kq&Qit-!N98qBZeGruinJ03HAL63PB`tkcA zf$a?_Q*Yz*jO%G}oBd8s!oZH6YHfOErnZ(=6Q;6W~;}K>oI#*vsU-g-lj%} zsjDUCsm+4VJ)6Pt%g5HaTc<8n*;Khmy|_KMMtEDKses=N$BbE_#M)2?QI7mWky3cx zaTJ%Cq|M+g)E3U}baC>?i*SowI0KLb+V4Nk_r`2<1XRM7AM|E6&PaOsp?LFX-5?_dv0<)-SPNY-SOZ~4MF#`vd`2Ap&BLcB9Qbzf^` z@iTndC9mfcuAH+@JFxG05H}}PgD)VR@QW*}8Ox@H$JU|=uh#3TY68r3E+8C#Pr!_T zwA5XSQh_xKV3u%Y*}SZf$I?h=JHK0!43(~upf{k(_@d~0=ceE~x-EIlc+v^Y=T3I8 zI*rSmnR`_>-fah8bo%<%x39&q(ssK-f{*WfP)(MZ?_dl*#z*$A>CDovI$G0OLN&)Q z+ShWm)&LvvOiXH?4jSn|4;ncAY0K3rTe*ulvaH7JW}4k-P|W5&ew~-d&a=jm@98aPG}c6N31ba^Ry`odTTaT zr5H~aKKe}k0nG4>y5&i^_7g9Tzkc&xG4JE9u@f=Sb0Ty#Jf_WU0nYnp;%3kGV&!kF&OpCpASS716TP z{h=s16!4}J`{k1?Zc8U+@A*tbj-Z?VD|S)CS^Xkj1p0}G3E$qOPfoEz27dsByco(( z;bQ@f*k9^NGr9ELY)whX{xQJ>$7vXauT{(zc#W5#5qZzNo;-23Z8wW1fW)6&XSHO> z=I&vt-$!je#OT2|6mFxQL3PnSpq+~l>lEz4gc%YUNytdqj_{JB&=fYxAGh=Ic8PWz|s!6@xS6`Q62uv z+#o%V?R@|nwfK&k3HN+Qw@rI<*2IK*-~?6OmQ!~(#3tM1OMpHKoq%+%z9_QEq*C*t zJ7tYEfC1Zbhf!;xbNOtHEk60h8INHC{bS(u&MhjI=ff$>`HjuNDtsB$`L>w<)PFaH zO=x$*%@k2qYszNLU$wecjGklf?fB}E$Spe=D;uD|8-)1m8m(zmOJ&>fCUx1wW+FA2 z_N#YqZ{#+!G}Pjw6E6C8AL(+x#!?DTTJrVE$PJNv z0+2RO#BO)VGvQ?i+ww}@xj;0Ak)%njUVH19pG3`?zmKZ|L z`*h`S_j}|I%W`qlbH!)JerJkTdhKL$3lyVnBOW%pYoe_0`(b6YrSEoSrW&P+PL>eu znt`*)kgKnT8yeMZG!f>7mAkWnM7hBu)+Rn9`7VpCjSdfLjs{Y~O`eGHS{w)a70j7Z zy@koH+2JtRaAd#q!d%?MsHoP6H1?g{>!i&oa|spvNv*I*hPNjJx>BWj=bmy}!0&`^ zO0r8KH~2#4g!}VDaT6w^s6E4Mm^_TEys9_LBV2gP!ZX37D24Yt0fy^yB$G+G#O0dID95< zlK7UueDZJ~j^!j5OY8B^@?N{>1%N`{KKy9(Jn$s(%PeB%-eHHMS(}K`+xB+3ucY{4 zI>{}Pz}NP8)akb6!g8jF+L-ZZ;n<}XXFC03d-MCm*Dsmt?QEEBM{wHR)*?7)FajKp zturq8(|9VDt%u7^^%J*Z5p4znCgs0SDFEBQa6SiRXj>DLLl=%K8)CJX0zh7<3-mG_ zaJ>mJA+w4yFq)WUUGwsbcX3(2{ zq!~O$7cXq%NlXVd9^G*miW53@LY&-iR~}j7&myW}&Y1jC5-xK`nf&zzDsv3hnEatU zod>5vx?gK#&U+s(+&@?R11irE#EL|?5U^d+>NdM=x)TfN+<4igx=|jAxh~-}Tk(AO z2&X*VufIPk%r$n(-O*}C`l5?h_{EQM z67II8hm~@BWNrB9aLuDGQiwLAy3Xc-i{8iPpZTV~O1sW{VCq)@h&5LH6OqTN{fbYv zfYYgMJE?>Wrok-dWk_7~x$JOREjJL8@6eowUy+V4rjbO^bsLfz#?r{0FkG1nD@^8C zVRdP>n9I21^wCwGeuJ&hI<8y4fi%%`+?Cr!2oQ+L+R!2r<5HAr_3@sFx%XrFQPHCR zzhAxockq9YOO}?;pFDX&`rjYy|KEiFeT_bN7KwZ{J1Ytvp!|2)^Cul;`p)tMPTAlu OPh=$(B>ss12>4%c=NXOw diff --git a/apps/site/tools/integrity.mjs b/apps/site/tools/integrity.mjs new file mode 100644 index 0000000..7d8200c --- /dev/null +++ b/apps/site/tools/integrity.mjs @@ -0,0 +1,236 @@ +// L0-무결성 — CSS 커스텀 프로퍼티와 정적 경로의 참조 무결성 검사. +// +// 왜 필요한가: stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 +// 없다"를 잡지 못한다. 미정의 변수는 선언 전체를 무효로 만들지만 콘솔 에러가 +// 없어서 조용히 살아남는다. 실측 사고 — cheongon-care/main.css 가 var(--dur-fast) +// 를 11회 쓰는데 tokens.css 에는 --dur-quick 만 있었다. 그 결과 transition 선언 +// 11개가 통째로 무효였고, 호버 전환이 전부 즉시 점프였다. +// +// 경로도 같은 이유로 검사한다. href/src 오타는 빌드가 통과하고 배포도 되지만 +// 사용자에게만 404 로 나타난다. +// +// 사용: node tools/integrity.mjs + +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"); + +let pass = 0; +const failures = []; + +function ok(label) { + pass++; + void label; +} +function fail(label, detail) { + failures.push(`FAIL ${label}${detail ? ` — ${detail}` : ""}`); +} + +/** + * CSS 주석을 제거한다. 주석 안의 var() 나 url() 은 참조가 아니다. + * 주석이 먹은 줄바꿈은 그대로 남겨 보고하는 행 번호가 원본과 어긋나지 않게 한다. + */ +function stripComments(css) { + return css.replace(/\/\*[\s\S]*?\*\//g, (m) => 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" }) },