feat(showcase): 트웬티온스 사이트를 전면 개편한다
All checks were successful
ci / build (push) Successful in 33s

This commit is contained in:
Yun Chan 2026-08-25 03:50:28 +09:00
parent cb3c323c9f
commit 9529e3747c
29 changed files with 8906 additions and 1326 deletions

View file

@ -1077,3 +1077,29 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고
- **히어로 헤드라인 줄 수 초과** — `pulsegate` 320px 에서 4줄 / 1440px 에서 3줄, `synapse-bci` 3줄 (카운트 규칙 상한 2줄)
전부 `node tools/showcase-audit.mjs <slug>` 로 재현된다.
## TWENTYOZ 쇼케이스 — Precision Spatial (2026-08-24)
### 제품·카피 기준
- 정보 구조와 사실 카피의 단일 기준은 `https://twentyoz.kr/`의 회사·제품·뉴스 공개 자료다.
- 메인 순서는 `현장 → 운영 원칙 → 제품 → 프로젝트 기록 → 회사 → 문의`다.
- 제품 상세는 `products.html` 한 페이지에서 VIVEN, EduBill, XD Hub, XD Works를 기능·기술·사양·운영 순서로 공개한다.
- 공개되지 않은 파트너 역할, 성과 수치, 채용 상태를 추정하지 않는다. 모든 한국어 인터페이스 카피는 존댓말 또는 공식 명사형을 사용한다.
### 시각·3D 규칙
- 콘셉트는 **정밀한 공간 계기**다. 평면 DOM은 직각·선·여백·타이포가 소유하고, 원형과 곡면은 3D 코어만 소유한다.
- 외부 하드서피스 모델은 Javier Martín Hidalgo의 `Scifi Drone 1.1`을 CC BY 4.0 조건으로 사용하며, 화면 푸터와 로컬 라이선스 파일에 저작자·원본·라이선스를 표시한다.
- 중심 모델은 전면 `z=0.46`, 호박색 신호 링은 `z=-0.48`, 청록색 스펙트럼 링은 더 크고 얇게 `z=-0.64`에 둔다. 두 링은 코어 뒤에서 깊이 가림되어 형태가 겹쳐 보이지 않아야 한다.
- 셰이더는 필드 셸, 제한적 bloom, 미세 색수차·비네트·그레인에만 사용한다. 모델 재질을 덮는 임의 `onBeforeCompile`은 금지한다.
- WebGL 실패·감소 모션·문서 비가시 상태에서는 정적 테마별 WebP와 완성된 DOM이 그대로 남아야 한다.
### 검증 기준
- `npm run test:e2e:twentyoz`: 메인 320/390/768/1440, 네 현장 상호작용, WebGL 모델·셰이더·이중 링, 라이트/다크 axe, 폴백, 제품 상세, 성능·런타임을 검사한다.
- `npm run test:design:gate`: 메인 쇼케이스의 죽은 CSS·토큰·메타·오버플로·타입·대비·터치·인셋을 검사한다.
- `npm run test:design:products`: 공통 CSS의 죽은 선택자는 메인 게이트에 위임하고 제품 상세의 실렌더 게이트를 검사한다.
- `npm run verify`: 위 트웬티온스 전용 게이트와 저장소 전체 L0~L6를 한 번에 실행한다.
- 3D 번들 예산은 gzip 200KB 이하이며 현재 기준은 166,225B다. GLB는 697,076B이며 로컬 폰트·폴백 이미지를 포함한 예산은 전용 E2E에서 하드 실패한다.
- 2026-08-25 국소 조정: 3D 화면 중심은 모바일 50%, 태블릿 74%, 소형 PC 64%, 와이드 PC 60%를 기준으로 하며, 중복 현장 상태는 보조기술에만 알리고 3D 모션 제어는 히어로 CTA 행에 둔다.

View file

@ -1,124 +1,21 @@
# 게이트 리포트 — 2026-08-23 06:40
# 게이트 리포트 — 2026-08-24 17:12
**전 항목 통과 — 배포 가능**
```
PASS L0 정적 — SKIP — checks.static=false
PASS gaon-lms 죽은 선택자 — 2개 CSS 전 클래스 사용 중(무시 2접두사)
PASS dure-enrollment 죽은 선택자 — 2개 CSS 전 클래스 사용 중(무시 2접두사)
PASS gaon-lms 토큰 위생 — 색 0·radius 0 리터럴(토큰 1파일)
PASS dure-enrollment 토큰 위생 — 색 0·radius 0 리터럴(토큰 1파일)
PASS gaon-lms SEO/meta — title 18자·desc 74자·OG✓·alt 0결
PASS gaon-lms/dashboard 오버플로 — 320/375/390/768/1440
PASS gaon-lms/dashboard h1 1줄
PASS gaon-lms/dashboard 수축 없음
PASS gaon-lms/dashboard 리듬·트랙
PASS gaon-lms/dashboard 대비 AA
PASS gaon-lms/students 오버플로 — 320/375/390/768/1440
PASS gaon-lms/students h1 — SKIP — 요소 없음
PASS gaon-lms/students 수축 없음
PASS gaon-lms/students 리듬·트랙
PASS gaon-lms/students 대비 AA
PASS gaon-lms/attendance 오버플로 — 320/375/390/768/1440
PASS gaon-lms/attendance h1 — SKIP — 요소 없음
PASS gaon-lms/attendance 수축 없음
PASS gaon-lms/attendance 리듬·트랙
PASS gaon-lms/attendance 대비 AA
PASS gaon-lms/grades 오버플로 — 320/375/390/768/1440
PASS gaon-lms/grades h1 — SKIP — 요소 없음
PASS gaon-lms/grades 수축 없음
PASS gaon-lms/grades 리듬·트랙
PASS gaon-lms/grades 대비 AA
PASS gaon-lms/files 오버플로 — 320/375/390/768/1440
PASS gaon-lms/files h1 — SKIP — 요소 없음
PASS gaon-lms/files 수축 없음
PASS gaon-lms/files 리듬·트랙
PASS gaon-lms/files 대비 AA
PASS gaon-lms/calendar 오버플로 — 320/375/390/768/1440
PASS gaon-lms/calendar h1 — SKIP — 요소 없음
PASS gaon-lms/calendar 수축 없음
PASS gaon-lms/calendar 리듬·트랙
PASS gaon-lms/calendar 대비 AA
PASS gaon-lms/counsel 오버플로 — 320/375/390/768/1440
PASS gaon-lms/counsel h1 — SKIP — 요소 없음
PASS gaon-lms/counsel 수축 없음
PASS gaon-lms/counsel 리듬·트랙
PASS gaon-lms/counsel 대비 AA
PASS gaon-lms/report 오버플로 — 320/375/390/768/1440
PASS gaon-lms/report h1 — SKIP — 요소 없음
PASS gaon-lms/report 수축 없음
PASS gaon-lms/report 리듬·트랙
PASS gaon-lms/report 대비 AA
PASS gaon-lms/notice 오버플로 — 320/375/390/768/1440
PASS gaon-lms/notice h1 — SKIP — 요소 없음
PASS gaon-lms/notice 수축 없음
PASS gaon-lms/notice 리듬·트랙
PASS gaon-lms/notice 대비 AA
PASS gaon-lms 스케일×폭 h1 — scales 1/1.3
PASS gaon-lms 브랜드 1줄
PASS gaon-lms/dashboard 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/students 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/attendance 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/grades 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/files 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/calendar 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/counsel 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/report 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/notice 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS gaon-lms/dashboard 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/students 왼쪽 인셋 — 최소 텍스트 시작 15px
PASS gaon-lms/attendance 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/grades 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/files 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/calendar 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/counsel 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/report 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS gaon-lms/notice 왼쪽 인셋 — 최소 텍스트 시작 16px
PASS dure-enrollment SEO/meta — title 15자·desc 79자·OG✓·alt 0결
PASS dure-enrollment/catalog 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/catalog h1 1줄
PASS dure-enrollment/catalog 수축 없음
PASS dure-enrollment/catalog 리듬·트랙
PASS dure-enrollment/catalog 대비 AA
PASS dure-enrollment/starred 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/starred h1 — SKIP — 요소 없음
PASS dure-enrollment/starred 수축 없음
PASS dure-enrollment/starred 리듬·트랙
PASS dure-enrollment/starred 대비 AA
PASS dure-enrollment/enrollments 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/enrollments h1 — SKIP — 요소 없음
PASS dure-enrollment/enrollments 수축 없음
PASS dure-enrollment/enrollments 리듬·트랙
PASS dure-enrollment/enrollments 대비 AA
PASS dure-enrollment/credits 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/credits h1 — SKIP — 요소 없음
PASS dure-enrollment/credits 수축 없음
PASS dure-enrollment/credits 리듬·트랙
PASS dure-enrollment/credits 대비 AA
PASS dure-enrollment/archive 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/archive h1 — SKIP — 요소 없음
PASS dure-enrollment/archive 수축 없음
PASS dure-enrollment/archive 리듬·트랙
PASS dure-enrollment/archive 대비 AA
PASS dure-enrollment/notices 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/notices h1 — SKIP — 요소 없음
PASS dure-enrollment/notices 수축 없음
PASS dure-enrollment/notices 리듬·트랙
PASS dure-enrollment/notices 대비 AA
PASS dure-enrollment 스케일×폭 h1 — scales 1/1.3
PASS dure-enrollment 브랜드 1줄
PASS dure-enrollment/catalog 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS dure-enrollment/starred 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS dure-enrollment/enrollments 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS dure-enrollment/credits 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS dure-enrollment/archive 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS dure-enrollment/notices 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS dure-enrollment/catalog 왼쪽 인셋 — 최소 텍스트 시작 24px
PASS dure-enrollment/starred 왼쪽 인셋 — 최소 텍스트 시작 24px
PASS dure-enrollment/enrollments 왼쪽 인셋 — 최소 텍스트 시작 24px
PASS dure-enrollment/credits 왼쪽 인셋 — 최소 텍스트 시작 24px
PASS dure-enrollment/archive 왼쪽 인셋 — 최소 텍스트 시작 24px
PASS dure-enrollment/notices 왼쪽 인셋 — 최소 텍스트 시작 24px
PASS 죽은 선택자 — SKIP — checks.deadCss=false
PASS twentyoz-products 토큰 위생 — 색 0·radius 1 리터럴(토큰 1파일)
PASS twentyoz-products SEO/meta — title 17자·desc 85자·OG✓·alt 0결
PASS twentyoz-products/single 오버플로 — 320/375/390/768/1440
PASS twentyoz-products/single h1 1줄
PASS twentyoz-products/single 수축 없음
PASS twentyoz-products/single 리듬·트랙
PASS twentyoz-products/single 대비 AA
PASS twentyoz-products 스케일×폭 h1 — scales 1/1.3
PASS twentyoz-products 브랜드 1줄
PASS twentyoz-products/single 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS twentyoz-products/single 왼쪽 인셋 — 최소 텍스트 시작 20px
PASS L3 시각 회귀 — SKIP — checks.visual=false
PASS L4 WebKit — SKIP — checks.webkit=false
```

View file

@ -32,6 +32,15 @@
],
"h1": "h2.v-h",
"brand": ".brand"
},
{
"name": "twentyoz-corp",
"path": "public/work/twentyoz-corp/index.html",
"views": [
"single"
],
"h1": "h1",
"brand": ".wordmark"
}
],
"widths": [

View file

@ -0,0 +1,43 @@
{
"pages": [
{
"name": "twentyoz-products",
"path": "public/work/twentyoz-corp/products.html",
"views": [
"single"
],
"h1": "h1",
"brand": ".wordmark"
}
],
"widths": [
320,
375,
390,
768,
1440
],
"fontScales": [
1,
1.3
],
"thresholds": {
"contrastNormal": 4.5,
"contrastLarge": 3,
"visualDiffPct": 0.1,
"stackLines": 3
},
"checks": {
"meta": true,
"contrast": true,
"stack": true,
"wrap": true,
"scaleMatrix": true,
"static": false,
"visual": false,
"webkit": false,
"deadCss": false,
"cssHygiene": true
},
"chromePath": null
}

View file

@ -5,6 +5,11 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"build:showcase-assets": "node tools/build-showcase-assets.mjs",
"test:design:gate": "node tools/design-gate.mjs --config=gate.config.json",
"test:design:products": "node tools/design-gate.mjs --config=gate.twentyoz-products.config.json",
"test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs",
"prebuild": "npm run build:showcase-assets",
"build": "astro build",
"preview": "astro preview",
"typecheck": "echo \"(사이트는 build 로 검증한다)\"",
@ -17,6 +22,7 @@
},
"devDependencies": {
"axe-core": "^4.13.0",
"esbuild": "^0.25.12",
"html-validate": "^10.11.3",
"pixelmatch": "^7.2.0",
"playwright": "^1.62.1",

View file

@ -0,0 +1,94 @@
Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
with Reserved Font Name Pretendard.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,14 @@
Model: Scifi Drone 1.1
Source: https://sketchfab.com/3d-models/scifi-drone-11-dd21953e81a24bd385d16eaf0c6a8ae7
Author: Javier Martin Hidalgo
Author page: https://sketchfab.com/Jj.Mmhh
License: Creative Commons Attribution 4.0 International (CC BY 4.0)
License URL: https://creativecommons.org/licenses/by/4.0/
This work is based on "Scifi Drone 1.1" by Javier Martin Hidalgo, licensed under CC BY 4.0.
Adaptations for this showcase:
- Converted the source glTF package to a single GLB.
- Resized textures to 1024 px and converted them to WebP.
- Quantized mesh and animation data for web delivery.
- Integrated the model into an original orbital field-instrument scene.

View file

@ -3,35 +3,30 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>트웬티온스 — 네 개의 현장에서 돌아가는 소프트웨어</title>
<title>트웬티온스 — 네 현장을 하나로</title>
<meta
name="description"
content="2020년 설립. 가상현실·산업용 IoT·이커머스 솔루션 전문 기업. VIVEN·EduBill·XD Hub·XD Works 네 개의 자체 제품이 강의실, 데이터센터, 공공기관, 공장 현장에서 돌아간다."
content="트웬티온스는 가상현실, 산업용 IoT, AI SaaS 결제, 현장 애플리케이션을 하나의 제품 체계로 설계하고 운영하는 소프트웨어 스튜디오입니다."
/>
<meta property="og:title" content="트웬티온스 — 네 개의 현장" />
<meta property="og:title" content="트웬티온스 — 네 현장을 하나로" />
<meta
property="og:description"
content="회사 소개 대신 한 일을 앞에 둔다. 6년 159건, 자체 제품 4종."
content="강의실, 데이터센터, 공공기관, 공장의 서로 다른 조건을 실제로 움직이는 소프트웨어로 연결합니다."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#fafaf8" />
<meta property="og:image" content="./og.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="theme-color" content="#0d0f0f" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#e9e7e2" media="(prefers-color-scheme: light)" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"
/>
<link rel="preload" href="./assets/pretendard-twentyoz.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="./styles/tokens.css" />
<link rel="stylesheet" href="./styles/main.css" />
<link
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='%23111214'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='15' font-weight='700' fill='%23FAFAF8' text-anchor='middle'%3E20%3C/text%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='%23151616'/%3E%3Ccircle cx='16' cy='16' r='7' fill='none' stroke='%23F6F4EF' stroke-width='2'/%3E%3Cpath d='M16 5v22M5 16h22' stroke='%23B9362B'/%3E%3C/svg%3E"
/>
<script defer src="./scripts/core-boot.js"></script>
</head>
<body>
@ -39,604 +34,387 @@
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#top">
<span class="wordmark-badge mono" aria-hidden="true">20</span>
<span>트웬티온스</span>
<a class="wordmark" href="#top" aria-label="트웬티온스 처음으로">
<span class="wordmark-mark" aria-hidden="true"><i></i><i></i></span>
<span>TWENTYOZ</span>
</a>
<nav class="nav" aria-label="주요 섹션">
<a href="#products">제품</a>
<a href="#ledger">한 일</a>
<a href="#record">기록</a>
<a href="#contact">연락</a>
<a href="#company">회사</a>
<a href="#contact">문의</a>
</nav>
<p class="head-meta mono">EST. 2020</p>
<p class="head-meta">HWASEONG · KR</p>
</div>
</header>
<main id="main">
<!-- ══════════════════════════════════════════════════════════
1 · 히어로 = 현장 선택기.
회사 이름과 슬로건을 첫 화면에 두지 않는다 — 감수한 리스크다
══════════════════════════════════════════════════════════ -->
<section class="band band-light hero" id="top" aria-labelledby="heroHeading">
<div class="wrap">
<h1 id="heroHeading">
네 개의 현장에서<br />
돌아가고 있다
</h1>
<p class="hero-lead">
트웬티온스는 2020년 1월에 세운 가상현실·산업용 IoT·이커머스 솔루션 회사입니다.
어느 현장의 이야기부터 보시겠습니까.
</p>
<section class="hero" id="top" aria-labelledby="heroHeading">
<div class="hero-stage" aria-hidden="true">
<picture class="core-poster">
<source media="(prefers-color-scheme: dark)" srcset="./assets/core-dark.webp" />
<img
src="./assets/core-light.webp"
width="1600"
height="900"
alt="트웬티온스의 네 산업 현장을 연결하는 정밀 공간 코어"
fetchpriority="high"
decoding="async"
/>
</picture>
<canvas id="coreCanvas"></canvas>
</div>
<form class="fields">
<div class="wrap hero-grid">
<div class="hero-copy">
<p class="eyebrow">Independent software studio · since 2020</p>
<h1 id="heroHeading">네 현장을 하나로</h1>
<p class="hero-lead">
가상현실부터 산업용 IoT까지. 서로 다른 현장의 데이터를 읽고,
사람이 바로 움직일 수 있는 하나의 시스템으로 바꿉니다.
</p>
<div class="hero-actions">
<a class="button button-primary" href="mailto:hello@twentyoz.kr?subject=%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%AC%B8%EC%9D%98">
프로젝트 문의
</a>
<a class="text-link" href="#fields">현장 선택</a>
<button class="motion-toggle" id="motionToggle" type="button" aria-pressed="false" hidden>
3D 모션 끄기
</button>
</div>
<output class="sr-only" id="coreStatus" aria-live="polite">선택한 현장: 강의실과 훈련장</output>
</div>
<form class="fields" id="fields">
<fieldset>
<legend class="sr-only">현장 고르기</legend>
<legend class="sr-only">트웬티온스가 일하는 현장 선택</legend>
<input type="radio" name="field" id="field-edu" class="field-radio" checked />
<input type="radio" name="field" id="field-dc" class="field-radio" />
<input type="radio" name="field" id="field-gov" class="field-radio" />
<input type="radio" name="field" id="field-plant" class="field-radio" />
<input type="radio" name="field" id="field-edu" class="field-radio" value="edu" checked />
<input type="radio" name="field" id="field-dc" class="field-radio" value="dc" />
<input type="radio" name="field" id="field-gov" class="field-radio" value="gov" />
<input type="radio" name="field" id="field-plant" class="field-radio" value="plant" />
<div class="field-tabs">
<label for="field-edu">
<span class="tab-num mono">01</span>
<span class="tab-name">강의실과 훈련장</span>
<label for="field-edu" aria-label="강의실과 훈련장">
<span class="tab-num">01</span>
<span class="tab-long">강의실과 훈련장</span>
<span class="tab-short">교육·훈련</span>
</label>
<label for="field-dc">
<span class="tab-num mono">02</span>
<span class="tab-name">데이터센터와 발전소</span>
<label for="field-dc" aria-label="데이터센터와 발전소">
<span class="tab-num">02</span>
<span class="tab-long">데이터센터와 발전소</span>
<span class="tab-short">데이터·에너지</span>
</label>
<label for="field-gov">
<span class="tab-num mono">03</span>
<span class="tab-name">공공기관과 대학 행정</span>
<label for="field-gov" aria-label="공공기관과 대학 행정">
<span class="tab-num">03</span>
<span class="tab-long">공공기관과 대학 행정</span>
<span class="tab-short">행정·결제</span>
</label>
<label for="field-plant">
<span class="tab-num mono">04</span>
<span class="tab-name">공장과 설비 현장</span>
<label for="field-plant" aria-label="공장과 설비 현장">
<span class="tab-num">04</span>
<span class="tab-long">공장과 설비 현장</span>
<span class="tab-short">설비·공정</span>
</label>
</div>
<div class="field-panels">
<div class="field-panels" aria-live="polite">
<article class="field-panel" data-panel="edu">
<p class="panel-what">
헤드셋을 쓴 학생이 실습을 하고, 강사는 그 장면을 밖에서 본다.
캐릭터의 몸짓은 웹캠 한 대로 잡는다.
</p>
<div class="panel-intro">
<p class="panel-index">Field 01 · Immersive learning</p>
<p class="panel-what">
헤드셋을 쓴 학생의 실습을 강사가 밖에서 보고, 웹캠 한 대로 캐릭터의 몸짓까지 잡습니다.
</p>
</div>
<dl class="panel-facts">
<div>
<dt>제품</dt>
<dd><strong>VIVEN</strong> — 몰입형 VR 교육·훈련 플랫폼</dd>
</div>
<div>
<dt>기술</dt>
<dd class="mono">Unity3D × WebXR × MediaPipe</dd>
</div>
<div>
<dt>특징</dt>
<dd>PC VMC · Mobile VMC 모듈로 캐릭터 실시간 모션캡처</dd>
</div>
<div>
<dt>납품 사례</dt>
<dd>미래기술학교 운영 (2024) · 메타버스 기반 교육 플랫폼</dd>
</div>
<div>
<dt>성숙도</dt>
<dd class="mono">TRL 9 · 2021 – 현재</dd>
</div>
<div><dt>제품</dt><dd><strong>VIVEN</strong> · 몰입형 VR 교육·훈련 플랫폼</dd></div>
<div><dt>기술</dt><dd>Unity3D × WebXR × MediaPipe</dd></div>
<div><dt>특징</dt><dd>PC VMC · Mobile VMC 실시간 모션캡처</dd></div>
<div><dt>납품</dt><dd>미래기술학교 운영 · 메타버스 기반 교육 플랫폼</dd></div>
<div><dt>성숙도</dt><dd>TRL 9 · 2021 – 현재</dd></div>
</dl>
</article>
<article class="field-panel" data-panel="dc">
<p class="panel-what">
설비 여섯 종류가 각자의 프로토콜로 떠드는 것을 한 엔진이 받아
같은 시간축에 올린다. 그 위에서 운전이 자동으로 돈다.
</p>
<div class="panel-intro">
<p class="panel-index">Field 02 · Connected operations</p>
<p class="panel-what">
서로 다른 여섯 산업 프로토콜을 한 엔진이 받아 같은 시간축에 올리고, 그 위에서 운전을 자동화합니다.
</p>
</div>
<dl class="panel-facts">
<div>
<dt>제품</dt>
<dd>
<strong>XD Hub</strong> — 산업용 IoT 데이터 통합 엔진<br />
<strong>XD Works</strong> — DCIM · FEMS 통합 운영 애플리케이션
</dd>
</div>
<div>
<dt>기술</dt>
<dd class="mono">
Java Spring Boot × Netty × TimescaleDB<br />
Spring Boot × NestJS × TypeScript
</dd>
</div>
<div>
<dt>특징</dt>
<dd>6종 산업 프로토콜을 단일 엔진으로 수집·전처리·전송</dd>
</div>
<div>
<dt>납품 사례</dt>
<dd>
과천 데이터 센터 · LS 일렉 청주 FEMS · 제주 폐기물 EMS ·
비금도 EMS · 한유 AFIMS (모두 2024)
</dd>
</div>
<div>
<dt>성숙도</dt>
<dd class="mono">TRL 8 · 2022 – 현재</dd>
</div>
<div><dt>제품</dt><dd><strong>XD Hub · XD Works</strong> · 데이터 통합과 운영 자동화</dd></div>
<div><dt>기술</dt><dd>Spring Boot × Netty × TimescaleDB × NestJS</dd></div>
<div><dt>특징</dt><dd>6종 산업 프로토콜 수집·전처리·전송</dd></div>
<div><dt>납품</dt><dd>과천 DC · LS 일렉 청주 FEMS · 제주·비금도 EMS · 한유 AFIMS</dd></div>
<div><dt>성숙도</dt><dd>TRL 8 · 2022 – 현재</dd></div>
</dl>
</article>
<article class="field-panel" data-panel="gov">
<p class="panel-what">
해외 AI 서비스를 쓰고 싶은데 법인카드도 세금계산서도 안 되는 기관이 있다.
그 사이에 들어가 원화 결제와 증빙을 대신한다.
</p>
<div class="panel-intro">
<p class="panel-index">Field 03 · Public AI access</p>
<p class="panel-what">
해외 AI 서비스를 쓰기 어려운 기관을 위해 원화 결제와 세금계산서 발행을 하나의 흐름으로 연결합니다.
</p>
</div>
<dl class="panel-facts">
<div>
<dt>제품</dt>
<dd><strong>EduBill</strong> — AI SaaS 구독 대행 플랫폼</dd>
</div>
<div>
<dt>기술</dt>
<dd class="mono">Next.js × TypeScript × Supabase PostgreSQL</dd>
</div>
<div>
<dt>쓰는 곳</dt>
<dd>공공기관 · 대학 · 초중고 · 기업 HRD</dd>
</div>
<div>
<dt>납품 사례</dt>
<dd>KDIS EAI (2024) · EduBill (2024)</dd>
</div>
<div>
<dt>성숙도</dt>
<dd class="mono">TRL 8 · 2024 – 현재</dd>
</div>
<div><dt>제품</dt><dd><strong>EduBill</strong> · AI SaaS 구독 대행 플랫폼</dd></div>
<div><dt>기술</dt><dd>Next.js × TypeScript × Supabase PostgreSQL</dd></div>
<div><dt>현장</dt><dd>공공기관 · 대학 · 초중고 · 기업 HRD</dd></div>
<div><dt>납품</dt><dd>KDIS EAI · EduBill</dd></div>
<div><dt>성숙도</dt><dd>TRL 8 · 2024 – 현재</dd></div>
</dl>
</article>
<article class="field-panel" data-panel="plant">
<p class="panel-what">
현장에서는 사무실 화면이 안 열린다. 장갑 낀 손과 밝은 햇빛,
끊기는 통신을 전제로 다시 만든다.
</p>
<div class="panel-intro">
<p class="panel-index">Field 04 · Industrial interface</p>
<p class="panel-what">
장갑 낀 손, 밝은 햇빛, 끊기는 통신을 전제로 사무실 화면을 현장용 인터페이스로 다시 만듭니다.
</p>
</div>
<dl class="panel-facts">
<div>
<dt>성격</dt>
<dd>고객사 설비·공정에 맞춰 짓는 현장 애플리케이션</dd>
</div>
<div>
<dt>납품 사례</dt>
<dd>
브래키츠 BSLIM · 영진EL 2.0 스마트 APP · LS 전선 부스덕트 (모두 2024)
</dd>
</div>
<div>
<dt>기술</dt>
<dd class="mono">Spring Boot / PostgreSQL / WebSocket · Vue3</dd>
</div>
<div>
<dt>가장 무거운 것</dt>
<dd>
ACS (Auto Control System) — Unity Engine · RFS 실시간 유체 시뮬레이션 ·
Transformer 기반 AI (2024)
</dd>
</div>
<div><dt>성격</dt><dd>고객사 설비·공정에 맞춘 현장 애플리케이션</dd></div>
<div><dt>기술</dt><dd>Spring Boot × PostgreSQL × WebSocket × Vue3</dd></div>
<div><dt>납품</dt><dd>브래키츠 BSLIM · 영진EL 2.0 · LS 전선 부스덕트</dd></div>
<div><dt>시뮬레이션</dt><dd>Unity Engine · RFS 실시간 유체 · Transformer AI</dd></div>
<div><dt>대표 시스템</dt><dd>ACS · Auto Control System</dd></div>
</dl>
</article>
</div>
</fieldset>
</form>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
2 · 회사
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-about" id="about" aria-labelledby="aboutHeading">
<div class="wrap split">
<div class="split-lead">
<h2 id="aboutHeading">여섯 해 동안 쌓인 것</h2>
</div>
<div class="split-body">
<p class="lead">
서로 다른 산업의 프로젝트 경험과 새 기술을 붙여, 고객이 다음 성장 동력을 찾도록
돕는 것이 하는 일입니다. 에너지, 건물 자동화, 헬스케어처럼 틀리면 곤란한 영역이
주 무대입니다.
</p>
<dl class="counts">
<div>
<dt>수행 프로젝트</dt>
<dd class="mono">159<span>건</span></dd>
</div>
<div>
<dt>고객사</dt>
<dd class="mono">42<span>곳</span></dd>
</div>
<div>
<dt>구성원</dt>
<dd class="mono">40<span>명</span></dd>
</div>
<div>
<dt>특허</dt>
<dd class="mono">4<span>건</span></dd>
</div>
</dl>
<p class="counts-note">
위 넷은 회사가 공개한 값입니다. 아래 <a href="#ledger">한 일</a> 목록에는
그중 공개 가능한 49건의 일부를 이름·연도·기술 스택과 함께 적었습니다.
<section class="section proof" aria-labelledby="proofHeading">
<div class="wrap proof-grid">
<div class="section-intro">
<p class="eyebrow">Operational proof</p>
<h2 id="proofHeading">운영 결과를 수치로 증명합니다</h2>
<p>
서로 다른 산업의 경험과 새 기술을 붙여 고객의 다음 운영 방식을 만드는 것이 하는 일입니다.
</p>
</div>
<dl class="counts">
<div><dt>수행 프로젝트</dt><dd>159<span>건</span></dd></div>
<div><dt>고객사</dt><dd>42<span>곳</span></dd></div>
<div><dt>구성원</dt><dd>40<span>명</span></dd></div>
<div><dt>특허</dt><dd>4<span>건</span></dd></div>
</dl>
<p class="source-note">
2026년 8월 24일 기준
<a href="https://twentyoz.kr/" target="_blank" rel="noreferrer">트웬티온스 공식 공개 자료</a>의 값입니다.
아래에는 공개 가능한 49건 중 12건을 이름·연도·기술과 함께 적었습니다.
</p>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
3 · 제품 — 반전 밴드. 제품은 기계다
══════════════════════════════════════════════════════════ -->
<section class="band band-dark section-products" id="products" aria-labelledby="prodHeading">
<section class="section principles" aria-labelledby="principlesHeading">
<div class="wrap">
<p class="eyebrow">자체 제품</p>
<h2 id="prodHeading">팔러 다니는 것이 아니라 계속 고치는 것</h2>
<p class="section-lead">
네 개 모두 특정 고객의 요구에서 시작해 제품이 됐습니다. 지금도 각자의 개발 주기가
돌아가고 있습니다.
</p>
<div class="section-intro section-intro-wide">
<p class="eyebrow">Operating principles · 04</p>
<h2 id="principlesHeading">서로 다른 기술을 하나의 운영 기준으로 연결합니다</h2>
<p>프로젝트의 분야가 달라도 설계와 검증에 적용하는 네 가지 원칙은 같습니다.</p>
</div>
<ol class="principle-list">
<li><p>01 / Hybrid</p><h3>교배</h3><span>산업 경험과 신기술을 결합해 현장에 필요한 새로운 운영 방식을 만듭니다.</span></li>
<li><p>02 / Continuum</p><h3>연속성</h3><span>단발성 납품에 머물지 않고 제품과 프로젝트를 지속적으로 고도화합니다.</span></li>
<li><p>03 / Precision</p><h3>정확성</h3><span>에너지·건물 자동화·헬스케어처럼 오차 비용이 큰 영역에서 정확성을 우선합니다.</span></li>
<li><p>04 / Empowerment</p><h3>확장성</h3><span>자동화된 운영과 데이터 기반 의사결정이 고객 조직 안에서 이어지도록 설계합니다.</span></li>
</ol>
</div>
</section>
<section class="section products" id="products" aria-labelledby="productsHeading">
<div class="wrap">
<div class="section-intro section-intro-wide">
<p class="eyebrow">Product system · 04</p>
<h2 id="productsHeading">한 번 납품하고 끝내지 않는 네 개의 시스템</h2>
<p>특정 고객의 요구에서 시작했지만, 지금도 각자의 개발 주기로 계속 고치고 있습니다.</p>
</div>
<ol class="product-list">
<li>
<p class="prod-id mono">V–01</p>
<div class="prod-body">
<h3>VIVEN <span class="prod-kind">메타버스 플랫폼</span></h3>
<p>
기업과 교육기관을 위한 몰입형 VR 교육·훈련 플랫폼. PC VMC 와 Mobile VMC
모듈로 캐릭터 실시간 모션캡처를 지원합니다.
</p>
<p class="prod-stack mono">Unity3D × WebXR × MediaPipe</p>
<p class="product-id">01 / 04</p>
<div class="product-main">
<h3>VIVEN</h3>
<p>기업과 교육기관을 위한 몰입형 VR 교육·훈련 플랫폼. PC와 모바일 VMC로 캐릭터의 몸짓을 실시간으로 연결합니다.</p>
<p class="tech-line">Unity3D × WebXR × MediaPipe</p>
<a class="product-link" href="./products.html#viven">VIVEN 상세 보기</a>
</div>
<dl class="prod-meta mono">
<div><dt>규모</dt><dd>Enterprise</dd></div>
<div><dt>주기</dt><dd>2021 –</dd></div>
<div><dt>성숙도</dt><dd>TRL 9</dd></div>
<dl class="product-meta">
<div><dt>Domain</dt><dd>Immersive learning</dd></div>
<div><dt>Cycle</dt><dd>2021 –</dd></div>
<div><dt>Maturity</dt><dd>TRL 9</dd></div>
</dl>
</li>
<li>
<p class="prod-id mono">V–02</p>
<div class="prod-body">
<h3>EduBill <span class="prod-kind">교육 SaaS</span></h3>
<p>
공공기관·대학·초중고·기업 HRD 를 위한 AI SaaS 구독 대행 플랫폼.
해외 AI 서비스를 원화 결제와 세금계산서 발행으로 들여옵니다.
</p>
<p class="prod-stack mono">Next.js × TypeScript × Supabase PostgreSQL</p>
<p class="product-id">02 / 04</p>
<div class="product-main">
<h3>EduBill</h3>
<p>공공기관·대학·초중고·기업 HRD가 해외 AI 서비스를 원화 결제와 세금계산서로 구독하도록 연결합니다.</p>
<p class="tech-line">Next.js × TypeScript × Supabase PostgreSQL</p>
<a class="product-link" href="./products.html#edubill">EduBill 상세 보기</a>
</div>
<dl class="prod-meta mono">
<div><dt>규모</dt><dd>B2G · 교육</dd></div>
<div><dt>주기</dt><dd>2024 –</dd></div>
<div><dt>성숙도</dt><dd>TRL 8</dd></div>
<dl class="product-meta">
<div><dt>Domain</dt><dd>Public AI access</dd></div>
<div><dt>Cycle</dt><dd>2024 –</dd></div>
<div><dt>Maturity</dt><dd>TRL 8</dd></div>
</dl>
</li>
<li>
<p class="prod-id mono">V–03</p>
<div class="prod-body">
<h3>XD Hub <span class="prod-kind">산업용 IoT</span></h3>
<p>
데이터센터·제조·에너지·빌딩 도메인을 위한 산업용 IoT 데이터 통합 엔진.
6종 산업 프로토콜을 단일 엔진으로 수집·전처리·전송합니다.
</p>
<p class="prod-stack mono">Java Spring Boot × Netty × TimescaleDB</p>
<p class="product-id">03 / 04</p>
<div class="product-main">
<h3>XD Hub</h3>
<p>데이터센터·제조·에너지·빌딩의 여섯 산업 프로토콜을 단일 엔진으로 수집하고 전처리해 전송합니다.</p>
<p class="tech-line">Java Spring Boot × Netty × TimescaleDB</p>
<a class="product-link" href="./products.html#xd-hub">XD Hub 상세 보기</a>
</div>
<dl class="prod-meta mono">
<div><dt>규모</dt><dd>설비</dd></div>
<div><dt>주기</dt><dd>2022 –</dd></div>
<div><dt>성숙도</dt><dd>TRL 8</dd></div>
<dl class="product-meta">
<div><dt>Domain</dt><dd>Industrial IoT</dd></div>
<div><dt>Cycle</dt><dd>2022 –</dd></div>
<div><dt>Maturity</dt><dd>TRL 8</dd></div>
</dl>
</li>
<li>
<p class="prod-id mono">V–04</p>
<div class="prod-body">
<h3>XD Works <span class="prod-kind">DCIM · FEMS</span></h3>
<p>
XD Hub 가 모은 데이터를 받아 데이터센터 코로케이션, 빌딩, 공장, 발전소의
운영을 자동화하는 통합 운영 애플리케이션입니다.
</p>
<p class="prod-stack mono">Spring Boot × NestJS × TypeScript</p>
<p class="product-id">04 / 04</p>
<div class="product-main">
<h3>XD Works</h3>
<p>XD Hub가 모은 데이터를 받아 데이터센터, 빌딩, 공장, 발전소의 운영을 자동화하는 통합 애플리케이션입니다.</p>
<p class="tech-line">Spring Boot × NestJS × TypeScript</p>
<a class="product-link" href="./products.html#xd-works">XD Works 상세 보기</a>
</div>
<dl class="prod-meta mono">
<div><dt>규모</dt><dd>설비 · B2B</dd></div>
<div><dt>주기</dt><dd>2023 –</dd></div>
<div><dt>성숙도</dt><dd>TRL 8</dd></div>
<dl class="product-meta">
<div><dt>Domain</dt><dd>DCIM · FEMS</dd></div>
<div><dt>Cycle</dt><dd>2023 –</dd></div>
<div><dt>Maturity</dt><dd>TRL 8</dd></div>
</dl>
</li>
</ol>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
4 · 한 일 — 프로젝트 원장
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-ledger" id="ledger" aria-labelledby="ledgerHeading">
<div class="wrap">
<h2 id="ledgerHeading">한 일</h2>
<p class="section-lead">
공개 가능한 49건 가운데 12건입니다. 회사 소개서에 쓰는 문장 대신 이름과 연도,
그리고 무엇으로 만들었는지를 적습니다.
</p>
<section class="section ledger-section" id="ledger" aria-labelledby="ledgerHeading">
<div class="wrap ledger-grid">
<div class="section-intro sticky-intro">
<p class="eyebrow">Selected work · 12 / 49</p>
<h2 id="ledgerHeading">프로젝트는 이름과 기술로 증명합니다</h2>
<p>
소개서 문장 대신 실제 프로젝트, 완료 연도, 사용 기술을 남깁니다.
마지막 항목은 GPU 위에서 공기 흐름을 실시간으로 풀어 제어를 검증합니다.
</p>
</div>
<table class="ledger">
<caption class="sr-only">트웬티온스 수행 프로젝트 12건 — 이름, 연도, 사용 기술</caption>
<thead>
<tr>
<th scope="col" class="col-id">번호</th>
<th scope="col">프로젝트</th>
<th scope="col" class="col-year">연도</th>
<th scope="col">기술</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono">001</td>
<th scope="row">EduBill</th>
<td class="mono">2024</td>
<td class="mono">Next.js · TypeScript · Supabase PostgreSQL</td>
</tr>
<tr>
<td class="mono">002</td>
<th scope="row">과천 데이터 센터</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · PostgreSQL</td>
</tr>
<tr>
<td class="mono">003</td>
<th scope="row">제주 폐기물 EMS</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · PostgreSQL</td>
</tr>
<tr>
<td class="mono">004</td>
<th scope="row">KDIS EAI</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · PostgreSQL</td>
</tr>
<tr>
<td class="mono">005</td>
<th scope="row">LS 일렉 청주 FEMS</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · PostgreSQL</td>
</tr>
<tr>
<td class="mono">006</td>
<th scope="row">LS 전선 부스덕트</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · PostgreSQL</td>
</tr>
<tr>
<td class="mono">007</td>
<th scope="row">브래키츠 BSLIM</th>
<td class="mono">2024</td>
<td class="mono">Spring Boot · PostgreSQL · WebSocket</td>
</tr>
<tr>
<td class="mono">008</td>
<th scope="row">영진EL 2.0 스마트 APP</th>
<td class="mono">2024</td>
<td class="mono">Vue3</td>
</tr>
<tr>
<td class="mono">009</td>
<th scope="row">미래기술학교 운영</th>
<td class="mono">2024</td>
<td class="mono">Unity Engine · Blender · Unreal Engine</td>
</tr>
<tr>
<td class="mono">010</td>
<th scope="row">한유 AFIMS</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · PostgreSQL</td>
</tr>
<tr>
<td class="mono">011</td>
<th scope="row">비금도 EMS</th>
<td class="mono">2024</td>
<td class="mono">Vue3 · Spring · Java</td>
</tr>
<tr class="row-mark">
<td class="mono">012</td>
<th scope="row">ACS (Auto Control System)</th>
<td class="mono">2024</td>
<td class="mono">Unity Engine · RFS 실시간 유체 시뮬레이션 · Transformer 기반 AI</td>
</tr>
</tbody>
</table>
<p class="ledger-note">
마지막 줄이 지금 가장 멀리 가 있는 일입니다. 데이터센터의 공기 흐름을 GPU 위에서
실시간으로 풀어 제어를 검증합니다.
</p>
<div class="table-wrap" tabindex="0" role="region" aria-label="트웬티온스 프로젝트 목록">
<table class="ledger">
<caption class="sr-only">트웬티온스 수행 프로젝트 12건</caption>
<thead>
<tr><th scope="col">No.</th><th scope="col">프로젝트</th><th scope="col">연도</th><th scope="col">기술</th></tr>
</thead>
<tbody>
<tr><td data-label="번호">001</td><th scope="row">EduBill</th><td data-label="연도">2024</td><td data-label="기술">Next.js · TypeScript · Supabase PostgreSQL</td></tr>
<tr><td data-label="번호">002</td><th scope="row">과천 데이터 센터</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · PostgreSQL</td></tr>
<tr><td data-label="번호">003</td><th scope="row">제주 폐기물 EMS</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · PostgreSQL</td></tr>
<tr><td data-label="번호">004</td><th scope="row">KDIS EAI</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · PostgreSQL</td></tr>
<tr><td data-label="번호">005</td><th scope="row">LS 일렉 청주 FEMS</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · PostgreSQL</td></tr>
<tr><td data-label="번호">006</td><th scope="row">LS 전선 부스덕트</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · PostgreSQL</td></tr>
<tr><td data-label="번호">007</td><th scope="row">브래키츠 BSLIM</th><td data-label="연도">2024</td><td data-label="기술">Spring Boot · PostgreSQL · WebSocket</td></tr>
<tr><td data-label="번호">008</td><th scope="row">영진EL 2.0 스마트 APP</th><td data-label="연도">2024</td><td data-label="기술">Vue3</td></tr>
<tr><td data-label="번호">009</td><th scope="row">미래기술학교 운영</th><td data-label="연도">2024</td><td data-label="기술">Unity Engine · Blender · Unreal Engine</td></tr>
<tr><td data-label="번호">010</td><th scope="row">한유 AFIMS</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · PostgreSQL</td></tr>
<tr><td data-label="번호">011</td><th scope="row">비금도 EMS</th><td data-label="연도">2024</td><td data-label="기술">Vue3 · Spring · Java</td></tr>
<tr class="ledger-focus"><td data-label="번호">012</td><th scope="row">ACS · Auto Control System</th><td data-label="연도">2024</td><td data-label="기술">Unity Engine · RFS 실시간 유체 시뮬레이션 · Transformer AI</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
5 · 함께 일한 곳
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-partners" id="partners" aria-labelledby="partnersHeading">
<div class="wrap">
<h2 id="partnersHeading">함께 일한 곳</h2>
<p class="section-lead">
대학과 대기업 설비 부문이 섞여 있습니다. 어느 쪽이든 검수 기준이 까다로운 곳입니다.
</p>
<ul class="partners">
<li>LS SAUTER</li>
<li>경희대학교</li>
<li>SAEKI P&amp;C</li>
<li>KDI School</li>
<li>COSS 사업단</li>
<li>클라우드브릭</li>
<li>한유</li>
<li>게임랩</li>
<li>METANET PLATFORM</li>
<li>안랩</li>
<li>강원대학교</li>
<li>중앙대학교</li>
<li>건국대학교</li>
<li>한화솔루션</li>
<li>가비아</li>
<li>GS칼텍스</li>
<li>SK하이닉스</li>
<li>LG디스플레이</li>
<li>대구한의대학교</li>
<section class="section company" id="company" aria-labelledby="companyHeading">
<div class="wrap company-grid">
<div class="section-intro">
<p class="eyebrow">Company record</p>
<h2 id="companyHeading">엄격한 검수 기준의 현장과 함께해 왔습니다</h2>
<p>대학, 공공기관, 대기업 설비 부문처럼 운영 중단의 비용이 큰 현장이 주 무대입니다.</p>
</div>
<ul class="partners" aria-label="함께 일한 곳">
<li>LS SAUTER</li><li>경희대학교</li><li>SAEKI P&amp;C</li><li>KDI School</li>
<li>COSS 사업단</li><li>클라우드브릭</li><li>한유</li><li>게임랩</li>
<li>METANET PLATFORM</li><li>안랩</li><li>강원대학교</li><li>중앙대학교</li>
<li>건국대학교</li><li>한화솔루션</li><li>가비아</li><li>GS칼텍스</li>
<li>SK하이닉스</li><li>LG디스플레이</li><li>대구한의대학교</li>
</ul>
<details class="archive" id="record" open>
<summary>
<span>최근 외부 기록</span>
<span>07 entries</span>
</summary>
<ol class="record-list">
<li><time datetime="2026-02-25">2026.02.25</time><div><h3><a href="https://twentyoz.kr/news/111" target="_blank" rel="noreferrer">K기술대상 산업대상 수상</a></h3><p>실감미디어·산업용 IoT 분야의 기술 혁신 성과를 인정받았습니다.</p></div><span>수상·인증</span></li>
<li><time datetime="2026-02-24">2026.02.24</time><div><h3>경기우수벤처기업 표창</h3><p>경기지방중소벤처기업청장상 · 기술·경영·혁신과 사회공헌 인정</p></div><span>수상·인증</span></li>
<li><time datetime="2026-01-20">2026.01.20</time><div><h3>2025 경기도 일자리 우수기업 인증</h3><p>고용증가율, 근무환경, 복리후생 제도 개선으로 인정</p></div><span>수상·인증</span></li>
<li><time datetime="2025-11-03">2025.11.03</time><div><h3>건국대, 시리아·요르단 교육기관 대표단 방문</h3><p>함께 개발한 메타버스 기반 교육 플랫폼 시연</p></div><span>교육·협력</span></li>
<li><time datetime="2025-10-31">2025.10.31</time><div><h3>중동 대표단, X-Space에서 K-에듀테크 체험</h3><p>메타버스 기반 교육 플랫폼 체험과 협력 논의</p></div><span>교육·협력</span></li>
<li><time datetime="2025-08-19">2025.08.19</time><div><h3>과기정통부 가상융합대학원 성과공유회</h3><p>서강대 개최 · AI 시대 인재양성 비전 논의 참여</p></div><span>행사</span></li>
<li><time datetime="2025-07-04">2025.07.04</time><div><h3>벤처기업 혁신성장유형 재인증</h3><p>기술 중심 비전과 디지털 혁신 노력 인정</p></div><span>수상·인증</span></li>
</ol>
</details>
<details class="archive career" id="career">
<summary>
<span>현재 채용 상태</span>
<span>정규 공고 마감</span>
</summary>
<div class="career-body">
<p>닫힌 공고를 열려 있는 것처럼 보이지 않게 상태를 그대로 적습니다.</p>
<ul class="jobs">
<li><strong>시니어 소프트웨어 엔지니어</strong><span>산업용 소프트웨어 · C++ / C# / Python · PLC · HMI · SCADA</span><em>마감</em></li>
<li><strong>DevOps 엔지니어</strong><span>컨테이너 · 오케스트레이션 · CI/CD · IaC</span><em>마감</em></li>
<li><strong>프로덕트 매니저</strong><span>IT 서비스 기획·운영 · 데이터 기반 의사결정</span><em>마감</em></li>
<li><strong>서비스 기획자</strong><span>UI/UX 기획 · 와이어프레임 · 애자일 / 스크럼</span><em>마감</em></li>
<li><strong>UI/UX 디자이너</strong><span>Figma · 웹 / 모바일 · 사용자 중심 설계</span><em>마감</em></li>
</ul>
<a class="text-link" href="mailto:hello@twentyoz.kr?subject=%EC%B1%84%EC%9A%A9%20%EB%AC%B8%EC%9D%98">채용 문의 보내기</a>
</div>
</details>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
6 · 기록 — 반전 밴드
══════════════════════════════════════════════════════════ -->
<section class="band band-dark section-record" id="record" aria-labelledby="recordHeading">
<div class="wrap">
<p class="eyebrow">최근 기록</p>
<h2 id="recordHeading">밖에서 확인된 것</h2>
<ol class="record-list">
<li>
<p class="rec-date mono">2026.02.24</p>
<div>
<h3>경기우수벤처기업 표창 — 경기지방중소벤처기업청장상</h3>
<p>기술·경영·혁신 능력이 뛰어나고 사회공헌도가 높은 벤처기업으로 인정</p>
</div>
<p class="rec-kind">수상 · 인증</p>
</li>
<li>
<p class="rec-date mono">2026.01.20</p>
<div>
<h3>2025 경기도 일자리 우수기업 인증</h3>
<p>고용증가율 10% 이상, 근무환경과 복리후생 제도 개선으로 인정</p>
</div>
<p class="rec-kind">수상 · 인증</p>
</li>
<li>
<p class="rec-date mono">2025.11.03</p>
<div>
<h3>건국대, 시리아·요르단 교육기관 대표단 방문</h3>
<p>트웬티온스와 함께 개발한 메타버스 기반 교육 플랫폼 시연</p>
</div>
<p class="rec-kind">교육 · 협력</p>
</li>
<li>
<p class="rec-date mono">2025.10.31</p>
<div>
<h3>중동 대표단, X-Space 에서 K-에듀테크 체험</h3>
<p>메타버스 기반 교육 플랫폼 체험과 협력 논의</p>
</div>
<p class="rec-kind">교육 · 협력</p>
</li>
<li>
<p class="rec-date mono">2025.08.19</p>
<div>
<h3>과기정통부 2025 가상융합대학원 성과공유회</h3>
<p>서강대 개최. AI 시대 인재양성 비전 논의에 참여</p>
</div>
<p class="rec-kind">행사</p>
</li>
<li>
<p class="rec-date mono">2025.07.04</p>
<div>
<h3>벤처기업 혁신성장유형 재인증</h3>
<p>기술 중심의 비전과 디지털 혁신 노력을 인정받음</p>
</div>
<p class="rec-kind">수상 · 인증</p>
</li>
</ol>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
7 · 채용
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-career" id="career" aria-labelledby="careerHeading">
<div class="wrap split">
<div class="split-lead">
<h2 id="careerHeading">지금은 정규 공고가 닫혀 있습니다</h2>
<section class="section contact" id="contact" aria-labelledby="contactHeading">
<div class="wrap contact-grid">
<div>
<p class="eyebrow">Start a project</p>
<h2 id="contactHeading">다음 현장을<br />함께 설계하겠습니다</h2>
</div>
<div class="split-body">
<p class="lead">
다섯 개 직무가 모두 마감 상태입니다. 닫힌 것을 열려 있는 것처럼 적어 두면
지원하는 쪽의 시간을 뺏게 되므로, 상태를 그대로 적습니다.
</p>
<ul class="jobs">
<li>
<span class="job-name">시니어 소프트웨어 엔지니어</span>
<span class="job-hint">산업용 소프트웨어 · C++ / C# / Python · PLC · HMI · SCADA</span>
<span class="job-state mono">마감</span>
</li>
<li>
<span class="job-name">DevOps 엔지니어</span>
<span class="job-hint">컨테이너 · 오케스트레이션 · CI/CD · IaC</span>
<span class="job-state mono">마감</span>
</li>
<li>
<span class="job-name">프로덕트 매니저</span>
<span class="job-hint">IT 서비스 기획·운영 3년 이상 · 데이터 기반 의사결정</span>
<span class="job-state mono">마감</span>
</li>
<li>
<span class="job-name">서비스 기획자</span>
<span class="job-hint">UI/UX 기획 · 와이어프레임 · 애자일 / 스크럼</span>
<span class="job-state mono">마감</span>
</li>
<li>
<span class="job-name">UI/UX 디자이너</span>
<span class="job-hint">Figma · 웹 / 모바일 포트폴리오 · 사용자 중심 설계</span>
<span class="job-state mono">마감</span>
</li>
</ul>
<p class="jobs-open">
공고와 무관하게 이야기를 시작할 수는 있습니다.
<a href="mailto:hello@twentyoz.kr?subject=%EC%B1%84%EC%9A%A9%20%EB%AC%B8%EC%9D%98">채용 문의 보내기</a>
</p>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
8 · 연락
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-contact" id="contact" aria-labelledby="contactHeading">
<div class="wrap split">
<div class="split-lead">
<h2 id="contactHeading">연락</h2>
</div>
<div class="split-body">
<dl class="contact-rows">
<div>
<dt>주소</dt>
<dd>경기도 화성시 동탄첨단산업1로 27, A909호<br />(영천동, 금강펜테리움 IX 타워)</dd>
</div>
<div>
<dt>전화</dt>
<dd><a class="mono" href="tel:+8207043531190">070-4353-1190</a></dd>
</div>
<div>
<dt>메일</dt>
<dd><a class="mono" href="mailto:hello@twentyoz.kr">hello@twentyoz.kr</a></dd>
</div>
<div>
<dt>설립</dt>
<dd class="mono">2020년 1월</dd>
</div>
<div class="contact-main">
<p>가상현실, 산업 데이터, 운영 자동화가 실제 현장에서 돌아가야 한다면 지금 이야기할 수 있습니다.</p>
<a class="contact-mail" href="mailto:hello@twentyoz.kr?subject=%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%AC%B8%EC%9D%98">hello@twentyoz.kr</a>
<dl class="contact-list">
<div><dt>Address</dt><dd>경기도 화성시 동탄첨단산업1로 27, A909호</dd></div>
<div><dt>Telephone</dt><dd><a href="tel:+8207043531190">070-4353-1190</a></dd></div>
<div><dt>Founded</dt><dd>2020년 1월</dd></div>
</dl>
<a class="btn" href="mailto:hello@twentyoz.kr?subject=%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%AC%B8%EC%9D%98">
프로젝트 문의
</a>
</div>
</div>
</section>
</main>
<footer class="band band-dark site-foot">
<div class="wrap foot-inner">
<p class="foot-name">트웬티온스 · TWENTYOZ</p>
<p class="foot-line">
가상현실 · 산업용 IoT · 이커머스 솔루션 · 경기도 화성시 동탄
<footer class="site-foot">
<div class="wrap foot-grid">
<p>TWENTYOZ · 트웬티온스</p>
<p>Virtual reality · Industrial IoT · Commerce solutions</p>
<p>© 2020–2026 TWENTYOZ. All rights reserved.</p>
<p class="model-credit">
3D field instrument adapted from
<a href="https://sketchfab.com/3d-models/scifi-drone-11-dd21953e81a24bd385d16eaf0c6a8ae7" rel="noopener noreferrer">Scifi Drone 1.1</a>
by <a href="https://sketchfab.com/Jj.Mmhh" rel="noopener noreferrer">Javier Martín Hidalgo</a>,
licensed under <a href="https://creativecommons.org/licenses/by/4.0/" rel="license noopener noreferrer">CC BY 4.0</a>.
<a href="./assets/twentyoz-field-core-LICENSE.txt">Attribution details</a>
</p>
<p class="foot-fine">© 2020–2026 TWENTYOZ</p>
</div>
</footer>
</body>

View file

@ -0,0 +1,161 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>제품 시스템 — TWENTYOZ</title>
<meta name="description" content="트웬티온스의 VIVEN, EduBill, XD Hub, XD Works가 제공하는 핵심 기능, 기술 구성, 운영 사양과 도입 흐름을 제품별로 확인합니다." />
<meta property="og:title" content="제품 시스템 — TWENTYOZ" />
<meta property="og:description" content="교육·AI 도입·산업 데이터·시설 운영을 연결하는 트웬티온스의 네 제품 시스템을 확인합니다." />
<meta property="og:type" content="website" />
<meta property="og:image" content="./assets/core-light.webp" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="theme-color" content="#0d0f0f" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#e9e7e2" media="(prefers-color-scheme: light)" />
<link rel="preload" href="./assets/pretendard-twentyoz.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="./styles/tokens.css" />
<link rel="stylesheet" href="./styles/main.css" />
<link rel="stylesheet" href="./styles/catalog.css" />
<link 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='%23151616'/%3E%3Ccircle cx='16' cy='16' r='7' fill='none' stroke='%23F6F4EF' stroke-width='2'/%3E%3Cpath d='M16 5v22M5 16h22' stroke='%23B9362B'/%3E%3C/svg%3E" />
</head>
<body class="catalog-page">
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="./index.html" aria-label="트웬티온스 메인으로">
<span class="wordmark-mark" aria-hidden="true"><i></i><i></i></span><span>TWENTYOZ</span>
</a>
<nav class="nav" aria-label="제품 상세 탐색">
<a href="#viven">VIVEN</a><a href="#edubill">EduBill</a><a href="#xd-hub">XD Hub</a><a href="#xd-works">XD Works</a>
</nav>
<p class="head-meta">PRODUCT SYSTEMS · 04</p>
</div>
</header>
<main class="catalog-main" id="main">
<section class="catalog-hero" aria-labelledby="catalogHeading">
<div class="wrap catalog-hero-grid">
<div>
<p class="eyebrow">Self IP · Product catalog</p>
<h1 id="catalogHeading">네 개의 시스템</h1>
</div>
<p class="catalog-lede">
교육과 훈련, 공공 AI 도입, 산업 데이터 수집, 시설 운영 자동화까지 각 제품의 기능과 기술 사양을 공개합니다.
</p>
<nav class="catalog-index" aria-label="제품 바로가기">
<a href="#viven"><span>V–01 · 2021</span><strong>VIVEN</strong></a>
<a href="#edubill"><span>V–02 · 2024</span><strong>EduBill</strong></a>
<a href="#xd-hub"><span>V–03 · 2022</span><strong>XD Hub</strong></a>
<a href="#xd-works"><span>V–04 · 2023</span><strong>XD Works</strong></a>
</nav>
</div>
</section>
<article class="specimen" id="viven" aria-labelledby="vivenHeading">
<div class="wrap">
<header class="specimen-heading">
<p class="specimen-code">V–01 / IMMERSIVE</p>
<h2 id="vivenHeading">VIVEN</h2>
<p class="specimen-summary">최대 100명이 VR·PC·모바일에서 함께 참여하는 교육 특화 메타버스 플랫폼입니다.</p>
</header>
<dl class="vital-grid">
<div><dt>Type</dt><dd>Self IP / Platform</dd></div><div><dt>Category</dt><dd>VR 교육·훈련</dd></div>
<div><dt>Build cycle</dt><dd>2021 – 현재</dd></div><div><dt>Maturity</dt><dd>TRL 9</dd></div>
</dl>
<div class="detail-grid">
<section aria-labelledby="vivenCapabilities">
<p class="detail-label">Capability matrix</p><h3 class="sr-only" id="vivenCapabilities">VIVEN 주요 기능</h3>
<ol class="capability-list">
<li><span>C–01</span><strong>실시간 협업</strong><p>단일 가상 공간에서 최대 100명의 교육과 훈련을 지원합니다.</p></li>
<li><span>C–02</span><strong>멀티 디바이스</strong><p>VR 헤드셋, PC, iOS와 Android 환경을 하나의 세션으로 연결합니다.</p></li>
<li><span>C–03</span><strong>AI 모션캡처</strong><p>PC VMC와 Mobile VMC로 웹캠 기반 아바타 동작을 실시간 반영합니다.</p></li>
<li><span>C–04</span><strong>성과 분석</strong><p>커스텀 시나리오, 교육 성과 리포트, 클라우드 콘텐츠 관리를 제공합니다.</p></li>
</ol>
</section>
<aside class="technical-panel" aria-label="VIVEN 기술과 사양">
<p class="detail-label">Technology lineage</p>
<ul class="stack-list"><li>Unity3D</li><li>WebXR</li><li>MediaPipe</li><li>Socket.IO</li><li>Spring Boot</li></ul>
<dl class="spec-list">
<div><dt>Devices</dt><dd>Oculus Quest, HTC Vive, PC, iOS·Android</dd></div>
<div><dt>Minimum</dt><dd>Windows 10 · Intel i5 · 8GB RAM · GTX 1060</dd></div>
<div><dt>Concurrency</dt><dd>단일 공간 최대 100명</dd></div>
<div><dt>Languages</dt><dd>한국어 · 영어</dd></div>
</dl>
</aside>
</div>
<section class="operation-flow" aria-label="VIVEN 운영 흐름">
<p class="detail-label">Operating sequence</p>
<ol><li data-step="01"><strong>시나리오 구성</strong><span>교육 목적에 맞는 가상 공간과 과정을 설계합니다.</span></li><li data-step="02"><strong>멀티 디바이스 접속</strong><span>VR·PC·모바일 사용자를 같은 세션으로 연결합니다.</span></li><li data-step="03"><strong>실시간 참여</strong><span>협업, 공간음향, 모션캡처를 동기화합니다.</span></li><li data-step="04"><strong>성과 확인</strong><span>활동 결과를 분석하고 리포트로 제공합니다.</span></li></ol>
</section>
<p class="specimen-action"><a class="button" href="mailto:hello@twentyoz.kr?subject=VIVEN%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98">VIVEN 도입 문의</a></p>
</div>
</article>
<article class="specimen" id="edubill" aria-labelledby="edubillHeading">
<div class="wrap">
<header class="specimen-heading">
<p class="specimen-code">V–02 / PUBLIC AI</p><h2 id="edubillHeading">EduBill</h2>
<p class="specimen-summary">공공기관과 교육기관이 해외 AI 서비스를 원화 결제와 세금계산서로 도입하도록 지원합니다.</p>
</header>
<dl class="vital-grid"><div><dt>Type</dt><dd>Self IP / SaaS</dd></div><div><dt>Category</dt><dd>EdTech 구독 관리</dd></div><div><dt>Build cycle</dt><dd>2024 – 현재</dd></div><div><dt>Maturity</dt><dd>TRL 8</dd></div></dl>
<div class="detail-grid">
<section aria-labelledby="edubillCapabilities"><p class="detail-label">Capability matrix</p><h3 class="sr-only" id="edubillCapabilities">EduBill 주요 기능</h3>
<ol class="capability-list">
<li><span>C–01</span><strong>원화 결제</strong><p>해외 AI 서비스 비용을 원화로 결제하고 세금계산서를 발행합니다.</p></li>
<li><span>C–02</span><strong>통합 라이선스</strong><p>ChatGPT, Claude, Gemini 등 여러 서비스의 라이선스를 함께 관리합니다.</p></li>
<li><span>C–03</span><strong>계약 자동화</strong><p>계약 만료와 자동 갱신 일정을 사전에 알립니다.</p></li>
<li><span>C–04</span><strong>일괄 배정</strong><p>기관, 부서, 사용자 단위로 라이선스를 배정하고 회수합니다.</p></li>
</ol>
</section>
<aside class="technical-panel" aria-label="EduBill 기술과 사양"><p class="detail-label">Technology lineage</p>
<ul class="stack-list"><li>Next.js</li><li>TypeScript</li><li>Supabase</li><li>PostgreSQL</li><li>Cloudflare Workers</li></ul>
<dl class="spec-list"><div><dt>Services</dt><dd>ChatGPT · Claude · Gemini 외</dd></div><div><dt>Audience</dt><dd>공공기관 · 대학 · 초중고 · 기업</dd></div><div><dt>Payment</dt><dd>원화 · 세금계산서</dd></div><div><dt>Management</dt><dd>기관 · 부서 · 사용자</dd></div></dl>
</aside>
</div>
<section class="operation-flow" aria-label="EduBill 운영 흐름"><p class="detail-label">Operating sequence</p><ol><li data-step="01"><strong>수요 취합</strong><span>기관별 서비스와 사용자 수를 확인합니다.</span></li><li data-step="02"><strong>라이선스 배정</strong><span>부서와 사용자 단위로 접근 권한을 구성합니다.</span></li><li data-step="03"><strong>결제·증빙</strong><span>원화 결제와 세금계산서 발행을 처리합니다.</span></li><li data-step="04"><strong>갱신 관리</strong><span>만료 일정과 사용 현황을 함께 관리합니다.</span></li></ol></section>
<p class="specimen-action"><a class="button" href="mailto:hello@twentyoz.kr?subject=EduBill%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98">EduBill 도입 문의</a></p>
</div>
</article>
<article class="specimen" id="xd-hub" aria-labelledby="xdHubHeading">
<div class="wrap">
<header class="specimen-heading"><p class="specimen-code">V–03 / INDUSTRIAL DATA</p><h2 id="xdHubHeading">XD Hub</h2><p class="specimen-summary">최대 100만 포인트를 실시간 처리하고 여섯 산업 프로토콜을 통합하는 IoT 데이터 엔진입니다.</p></header>
<dl class="vital-grid"><div><dt>Type</dt><dd>Self IP / Engine</dd></div><div><dt>Category</dt><dd>산업용 IoT</dd></div><div><dt>Build cycle</dt><dd>2022 – 현재</dd></div><div><dt>Maturity</dt><dd>TRL 8</dd></div></dl>
<div class="detail-grid">
<section aria-labelledby="xdHubCapabilities"><p class="detail-label">Capability matrix</p><h3 class="sr-only" id="xdHubCapabilities">XD Hub 주요 기능</h3><ol class="capability-list">
<li><span>C–01</span><strong>멀티 프로토콜</strong><p>Modbus, OPC UA, SNMP, BACnet, MQTT, REST를 한 엔진으로 수집합니다.</p></li>
<li><span>C–02</span><strong>히스토리안</strong><p>실시간 데이터를 시계열 저장소에 자동 보관하고 조회합니다.</p></li>
<li><span>C–03</span><strong>Web HMI</strong><p>SCADA 화면을 웹에서 구성하고 현장 상태를 시각화합니다.</p></li>
<li><span>C–04</span><strong>하이브리드 배포</strong><p>엣지와 클라우드 환경을 현장 조건에 맞게 함께 구성합니다.</p></li>
</ol></section>
<aside class="technical-panel" aria-label="XD Hub 기술과 사양"><p class="detail-label">Technology lineage</p><ul class="stack-list"><li>Java Spring Boot</li><li>Netty</li><li>TimescaleDB</li><li>Redis</li><li>Caffeine</li></ul><dl class="spec-list"><div><dt>Points</dt><dd>최대 1,000,000개</dd></div><div><dt>Protocols</dt><dd>Modbus · OPC UA · SNMP · BACnet · MQTT · REST</dd></div><div><dt>Storage</dt><dd>TimescaleDB · Redis</dd></div><div><dt>Domains</dt><dd>데이터센터 · 제조 · 발전소 · 빌딩</dd></div></dl></aside>
</div>
<section class="operation-flow" aria-label="XD Hub 운영 흐름"><p class="detail-label">Operating sequence</p><ol><li data-step="01"><strong>프로토콜 연결</strong><span>서로 다른 설비 통신 규격을 게이트웨이에 연결합니다.</span></li><li data-step="02"><strong>수집·전처리</strong><span>실시간 값을 정규화하고 품질을 확인합니다.</span></li><li data-step="03"><strong>시계열 저장</strong><span>히스토리안과 캐시에 운영 데이터를 보관합니다.</span></li><li data-step="04"><strong>전송·시각화</strong><span>운영 애플리케이션과 Web HMI에 데이터를 제공합니다.</span></li></ol></section>
<p class="specimen-action"><a class="button" href="mailto:hello@twentyoz.kr?subject=XD%20Hub%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98">XD Hub 도입 문의</a></p>
</div>
</article>
<article class="specimen" id="xd-works" aria-labelledby="xdWorksHeading">
<div class="wrap">
<header class="specimen-heading"><p class="specimen-code">V–04 / OPERATIONS</p><h2 id="xdWorksHeading">XD Works</h2><p class="specimen-summary">XD Hub의 IoT 데이터를 기반으로 데이터센터와 시설의 운영, 점검, 과금을 자동화합니다.</p></header>
<dl class="vital-grid"><div><dt>Type</dt><dd>Self IP / Application</dd></div><div><dt>Category</dt><dd>DCIM · BEMS · FM</dd></div><div><dt>Build cycle</dt><dd>2023 – 현재</dd></div><div><dt>Maturity</dt><dd>TRL 8</dd></div></dl>
<div class="detail-grid">
<section aria-labelledby="xdWorksCapabilities"><p class="detail-label">Capability matrix</p><h3 class="sr-only" id="xdWorksCapabilities">XD Works 주요 기능</h3><ol class="capability-list">
<li><span>C–01</span><strong>상면·계약·청구</strong><p>데이터센터 상면과 고객 계약, 사용량 기반 청구를 통합 관리합니다.</p></li>
<li><span>C–02</span><strong>에너지 분석</strong><p>BEMS 데이터를 분석하고 PUE를 자동 산정합니다.</p></li>
<li><span>C–03</span><strong>시설물 점검</strong><p>정기 점검과 유지보수 작업의 책임과 이력을 관리합니다.</p></li>
<li><span>C–04</span><strong>다채널 알림</strong><p>SMS, 알림톡, 이메일로 운영 이상과 업무 일정을 전달합니다.</p></li>
</ol></section>
<aside class="technical-panel" aria-label="XD Works 기술과 사양"><p class="detail-label">Technology lineage</p><ul class="stack-list"><li>Spring Boot</li><li>NestJS</li><li>TypeScript</li><li>PostgreSQL</li><li>Vue 3</li></ul><dl class="spec-list"><div><dt>Domains</dt><dd>DCIM · BEMS · FM</dd></div><div><dt>Billing</dt><dd>다통화 · 다과금 정책</dd></div><div><dt>Alerts</dt><dd>SMS · 알림톡 · 이메일</dd></div><div><dt>Audience</dt><dd>데이터센터 · 빌딩 · 공장 · 발전소</dd></div></dl></aside>
</div>
<section class="operation-flow" aria-label="XD Works 운영 흐름"><p class="detail-label">Operating sequence</p><ol><li data-step="01"><strong>자산·계약 구성</strong><span>공간, 설비, 고객, 계약 정보를 연결합니다.</span></li><li data-step="02"><strong>실시간 운영</strong><span>XD Hub의 계측 데이터를 운영 화면에 반영합니다.</span></li><li data-step="03"><strong>점검·분석</strong><span>유지보수와 에너지 성과를 같은 흐름에서 관리합니다.</span></li><li data-step="04"><strong>과금·알림</strong><span>사용량을 계산하고 청구서와 운영 알림을 발행합니다.</span></li></ol></section>
<p class="specimen-action"><a class="button" href="mailto:hello@twentyoz.kr?subject=XD%20Works%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98">XD Works 도입 문의</a></p>
</div>
</article>
<p class="wrap catalog-source">제품 정보는 2026년 8월 24일 기준 <a href="https://twentyoz.kr/" rel="noopener noreferrer">트웬티온스 공식 공개 자료</a>를 바탕으로 구성했습니다.</p>
</main>
<footer class="site-foot"><div class="wrap foot-grid"><p>TWENTYOZ · 트웬티온스</p><p>Product systems · Technical specifications</p><p>© 2020–2026 TWENTYOZ. All rights reserved.</p></div></footer>
</body>
</html>

View file

@ -0,0 +1,100 @@
(() => {
const hero = document.querySelector(".hero");
const canvas = document.getElementById("coreCanvas");
const toggle = document.getElementById("motionToggle");
const status = document.getElementById("coreStatus");
const inputs = [...document.querySelectorAll('input[name="field"]')];
const ownScript = document.currentScript;
if (!hero || !canvas || !toggle || !status || !ownScript || inputs.length !== 4) return;
const stateLabels = [
"선택한 현장: 강의실과 훈련장",
"선택한 현장: 데이터센터와 발전소",
"선택한 현장: 공공기관과 대학 행정",
"선택한 현장: 공장과 설비 현장",
];
let controller = null;
function selectedIndex() {
const checked = inputs.findIndex((input) => input.checked);
return checked < 0 ? 0 : checked;
}
function syncState() {
const index = selectedIndex();
hero.dataset.coreState = inputs[index].value;
status.textContent = stateLabels[index];
controller?.setState(index);
}
inputs.forEach((input) => input.addEventListener("change", syncState));
syncState();
function gate() {
const explicitWebgl = new URLSearchParams(location.search).has("webgl");
if ((navigator.webdriver || location.protocol === "file:") && !explicitWebgl) return "automation";
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return "reduced-motion";
if (navigator.connection?.saveData) return "save-data";
if ((navigator.deviceMemory ?? 8) < 4) return "low-memory";
if ((navigator.hardwareConcurrency ?? 8) < 4) return "low-cpu";
try {
const probe = document.createElement("canvas");
const gl = probe.getContext("webgl2", { failIfMajorPerformanceCaveat: true });
if (!gl) return "no-webgl2";
gl.getExtension("WEBGL_lose_context")?.loseContext();
return "ready";
} catch {
return "no-webgl2";
}
}
const result = gate();
if (result !== "ready") {
hero.dataset.webgl = "fallback";
hero.dataset.webglReason = result;
return;
}
function loadCore() {
if (hero.dataset.webgl === "loading" || hero.dataset.webgl === "ready") return;
hero.dataset.webgl = "loading";
const script = document.createElement("script");
script.src = new URL("../assets/core.iife.js", ownScript.src).href;
script.async = true;
script.onload = () => {
try {
controller = globalThis.TwentyozCore.mount({
canvas,
hero,
toggle,
initialState: selectedIndex(),
});
addEventListener("pagehide", () => controller?.dispose(), { once: true });
} catch {
hero.dataset.webgl = "fallback";
hero.dataset.webglReason = "mount-failed";
toggle.hidden = true;
}
};
script.onerror = () => {
hero.dataset.webgl = "fallback";
hero.dataset.webglReason = "asset-failed";
toggle.hidden = true;
};
document.head.append(script);
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
const schedule = window.requestIdleCallback || ((callback) => setTimeout(callback, 1));
schedule(loadCore, { timeout: 1200 });
},
{ rootMargin: "200px" },
);
observer.observe(hero);
matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => location.reload());
})();

View file

@ -0,0 +1,352 @@
.catalog-page .site-head {
position: fixed;
background: color-mix(in srgb, var(--page) 88%, transparent);
backdrop-filter: blur(18px);
}
.catalog-main {
overflow: clip;
}
.catalog-hero {
min-height: 76svh;
display: grid;
align-items: end;
padding-block: calc(var(--header-h) + var(--space-8)) var(--space-7);
border-block-end: var(--hairline) solid var(--line-strong);
}
.catalog-hero-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(16rem, 0.75fr);
gap: var(--space-7);
align-items: end;
}
.catalog-hero h1 {
max-width: 11ch;
margin-block-start: var(--space-5);
font-size: clamp(3.5rem, 2rem + 7vw, 7.5rem);
font-weight: 500;
line-height: 0.94;
letter-spacing: -0.045em;
white-space: nowrap;
}
.catalog-lede {
max-width: var(--measure);
color: var(--ink-muted);
font-size: var(--text-lead);
}
.catalog-index {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-block-start: var(--space-7);
border-block: var(--hairline) solid var(--line-strong);
}
.catalog-index a {
min-height: 5.5rem;
display: grid;
align-content: center;
gap: var(--space-1);
padding: var(--space-3) var(--space-4);
border-inline-start: var(--hairline) solid var(--line);
text-decoration: none;
}
.catalog-index a:first-child {
border-inline-start: 0;
}
.catalog-index span {
color: var(--signal-strong);
font-size: var(--text-xs);
letter-spacing: var(--tracking-label);
}
.catalog-index strong {
font-size: var(--text-body);
font-weight: 620;
}
.specimen {
scroll-margin-top: calc(var(--header-h) + var(--space-3));
padding-block: var(--space-9);
border-block-end: var(--hairline) solid var(--line-strong);
}
.specimen:nth-of-type(even) {
background: var(--surface-quiet);
}
.specimen-heading {
display: grid;
grid-template-columns: minmax(5rem, 0.3fr) minmax(0, 1.35fr) minmax(17rem, 0.65fr);
gap: var(--space-6);
align-items: end;
}
.specimen-code {
align-self: start;
color: var(--signal-strong);
font-size: var(--text-xs);
font-weight: 650;
letter-spacing: var(--tracking-label);
}
.specimen h2 {
font-size: clamp(3.5rem, 2.5rem + 4vw, 7rem);
font-weight: 500;
line-height: 0.94;
letter-spacing: -0.04em;
}
.specimen-summary {
color: var(--ink-muted);
font-size: var(--text-lead);
}
.vital-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-block-start: var(--space-7);
border-block: var(--hairline) solid var(--line-strong);
}
.vital-grid div {
min-width: 0;
padding: var(--space-4);
border-inline-start: var(--hairline) solid var(--line);
}
.vital-grid div:first-child {
border-inline-start: 0;
}
.vital-grid dt,
.spec-list dt {
color: var(--ink-muted);
font-size: var(--text-xs);
letter-spacing: var(--tracking-label);
}
.vital-grid dd {
margin-block-start: var(--space-3);
font-size: var(--text-sm);
font-weight: 620;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(18rem, 0.75fr);
gap: var(--space-7);
margin-block-start: var(--space-8);
align-items: start;
}
.detail-label {
margin-block-end: var(--space-4);
color: var(--ink-muted);
font-size: var(--text-xs);
letter-spacing: var(--tracking-label);
}
.capability-list {
border-block-start: var(--hairline) solid var(--line-strong);
}
.capability-list li {
display: grid;
grid-template-columns: 4.5rem minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: var(--space-4);
padding-block: var(--space-4);
border-block-end: var(--hairline) solid var(--line);
}
.capability-list span {
color: var(--signal-strong);
font-size: var(--text-xs);
}
.capability-list strong {
font-size: var(--text-body);
font-weight: 620;
}
.capability-list p {
color: var(--ink-muted);
font-size: var(--text-sm);
}
.technical-panel {
position: sticky;
inset-block-start: calc(var(--header-h) + var(--space-5));
}
.stack-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-block-end: var(--space-6);
}
.stack-list li {
padding: var(--space-1) var(--space-2);
border: var(--hairline) solid var(--line-strong);
font-size: var(--text-xs);
}
.spec-list {
border-block-start: var(--hairline) solid var(--line-strong);
}
.spec-list div {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr);
gap: var(--space-3);
padding-block: var(--space-3);
border-block-end: var(--hairline) solid var(--line);
}
.spec-list dd {
font-size: var(--text-sm);
}
.operation-flow {
margin-block-start: var(--space-8);
padding-block-start: var(--space-5);
border-block-start: var(--hairline) solid var(--line-strong);
}
.operation-flow ol {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-block-start: var(--space-4);
}
.operation-flow li {
position: relative;
min-height: 10rem;
padding: var(--space-4);
border-inline-start: var(--hairline) solid var(--line);
}
.operation-flow li:first-child {
border-inline-start: 0;
}
.operation-flow li::before {
content: attr(data-step);
display: block;
margin-block-end: var(--space-4);
color: var(--signal-strong);
font-size: var(--text-xs);
}
.operation-flow strong {
display: block;
margin-block-end: var(--space-2);
font-size: var(--text-sm);
}
.operation-flow span {
color: var(--ink-muted);
font-size: var(--text-xs);
line-height: 1.65;
}
.specimen-action {
display: flex;
justify-content: flex-end;
margin-block-start: var(--space-6);
}
.catalog-source {
padding-block: var(--space-6);
color: var(--ink-soft);
font-size: var(--text-xs);
}
.catalog-source a {
display: inline-block;
min-height: 1.5rem;
line-height: 1.5rem;
text-underline-offset: 0.2em;
}
@media (max-width: 62rem) {
.catalog-hero-grid,
.specimen-heading,
.detail-grid {
grid-template-columns: 1fr;
}
.catalog-lede {
max-width: 34rem;
}
.technical-panel {
position: static;
}
.principle-list,
.operation-flow ol {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 43rem) {
.catalog-page .site-head {
position: relative;
}
.catalog-hero {
min-height: 0;
padding-block: var(--space-7);
}
.catalog-hero h1 {
font-size: clamp(1.8rem, 9.5vw, 3rem);
}
.catalog-index,
.vital-grid,
.operation-flow ol {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalog-index a:nth-child(3),
.vital-grid div:nth-child(3),
.operation-flow li:nth-child(3) {
border-inline-start: 0;
}
.catalog-index a:nth-child(n + 3),
.vital-grid div:nth-child(n + 3),
.operation-flow li:nth-child(n + 3) {
border-block-start: var(--hairline) solid var(--line);
}
.specimen {
padding-block: var(--space-8);
}
.specimen h2 {
font-size: clamp(3rem, 17vw, 5rem);
}
.capability-list li {
grid-template-columns: 3.5rem minmax(0, 1fr);
}
.capability-list p {
grid-column: 2;
}
.operation-flow li {
min-height: 9rem;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,117 +1,151 @@
/* ==========================================================================
트웬티온스 (TWENTYOZ) — 회사 홈페이지 리디자인 · 디자인 토큰 SSOT
/*
TWENTYOZ · precision-spatial token source of truth
One grotesk, one semantic signal, square DOM geometry. Curves belong to the core.
*/
컨셉 : 네 개의 현장 — 회사 소개가 아니라, 같은 기술이 서로 다른 현장에서
무엇을 하고 있는지의 목록이 이 사이트의 본체다
프리셋: field-record (신규 정의. swiss-minimal 을 뼈대로 삼되 본문을 키웠다)
강조색: 신호 적색 1색. 라이트/다크는 규칙으로 정의된 한 쌍이므로 1종으로 센다
── 0단계 감사 · 현행 twentyoz.kr 실측 (2026-08-24, 1249px computed style)
유지할 자산
· radius 0px 742회 — 형태 어휘가 이미 통일돼 있다. 그대로 가져간다
· 무채색 기조와 모노스페이스(JetBrains Mono 356회) — 정밀함의 어휘는 맞다
· 실제 프로젝트 49건 · 파트너 19곳 · 제품 4종의 기술 스택 표기
고쳐야 할 것
· 대비 실패 **172개 요소** — rgb(102,102,102) 가 배경 rgb(2,2,2) 위에서 3.61:1,
그것도 9~11px 로 284곳에 쓰였다. WCAG AA 미달이고 읽기 자체가 어렵다
· font-family 가 Inter 뿐이라 **한글 폰트가 지정돼 있지 않다**.
한국 회사의 한국어 사이트에서 한글이 시스템 기본값으로 떨어진다
· h1 174.86px 에 letter-spacing −10.49px = **−0.06em**. 한글 상한(−0.02em)의 세 배다
· 배경 #020202 — 순흑에 가깝다
· 'Digital Genetics' 은유가 전면에 있다(CULTIVATING · DROPS · Sativa/Indica ·
Germination · Wholesale Desk · ACQUIRE GENETICS · Open Specimens).
SK하이닉스·LG디스플레이·한화솔루션·GS칼텍스에 산업용 IoT 를 파는 회사의
어휘로는 사업을 가린다. 은유를 걷어내고 한 일을 앞에 둔다
── 레퍼런스 실측 (2026-08-24, 1440px)
R1 구조 · samsara.com (커넥티드 오퍼레이션 / 산업 IoT — 같은 업종)
배경 #FFFFFF · FK Grotesk + FK Grotesk Mono 2패밀리
h1 72px/700 · 본문 16px(404회) · 최대 80px ÷ 본문 = 5.0배
radius 0px 2399회 지배 · 강조색 #FEAE0F 이 버튼 1곳
컨테이너 1425 전폭 ↔ 1345 ↔ 427 칼럼 · 섹션 13
→ 가져온 원리: 섹션 제목이 '무엇을 증명하는가'를 직접 말한다
(Proven where it counts / A track record / Proudly rated #1).
B2B 는 주장이 아니라 기록으로 설득한다
→ 안 가져온 것: 전체 대문자 h1 과 대문자 eyebrow 5종(슬롭 지문 2위),
순백·순흑, 섹션 13개, 앰버 pill 버튼
R2 톤 · lars-mueller-publishers.com (출판사 — 다른 업종)
텍스트 #414141 (순흑 아님) · NB Akademie Std 한 패밀리의 두 웨이트
h1 34px/500 · **본문 20px(190회)** · line-height 30px = 1.5
최대 ÷ 본문 = 1.7배 · radius 0px 1709회 지배 · 컨테이너 1240px
→ 가져온 원리: **본문을 크게 쓰고 제목을 작게 둔다.** 위계를 크기 하나로
만들지 않고 여백과 순서로도 만든다. 한 패밀리의 두 웨이트로 절제한다
→ 안 가져온 것: 강조색 #542BFF(보라 계열 — 슬롭 지문),
최대÷본문 1.7배(평평한 스케일은 안티패턴)
두 레퍼런스의 합성: 본문은 R2 쪽으로 키우고(17px), 최대÷본문은 R1 쪽으로
벌린다(4.0배). 한쪽만 따르면 평평하거나 본문이 작아진다.
========================================================================== */
@font-face {
font-family: "Pretendard Variable";
font-style: normal;
font-weight: 45 920;
font-display: swap;
src: url("../assets/pretendard-twentyoz.woff2") format("woff2-variations");
}
:root {
/* ── 타이포그래피 — 5단계 + 스케일 밖 디스플레이 1개 ── */
--step--1: 0.8125rem; /* 13px — 표 머리·메타 */
--step-0: 1.0625rem; /* 17px — 본문. R2 의 큰 본문을 한글에 맞춰 옮겼다 */
--step-1: 1.25rem; /* 20px — 리드 */
--step-2: clamp(1.375rem, 1.15rem + 1.1vw, 1.9375rem); /* 22 → 31px */
--step-3: clamp(1.75rem, 1.25rem + 2.5vw, 2.875rem); /* 28 → 46px */
--display: clamp(2.25rem, 1.3rem + 4.6vw, 4.25rem); /* 36 → 68px */
/* 최대 ÷ 본문 = 68 ÷ 17 = 4.0배 */
color-scheme: light dark;
--leading-tight: 1.2;
--leading-normal: 1.7;
--leading-loose: 1.85;
--measure: 33em;
/* Type */
--font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-body: clamp(1.0625rem, 1.02rem + 0.16vw, 1.125rem);
--text-lead: clamp(1.125rem, 1.02rem + 0.38vw, 1.3125rem);
--text-h3: clamp(1.5rem, 1.25rem + 0.75vw, 1.875rem);
--text-h2: clamp(2rem, 1.75rem + 0.8vw, 2.5rem);
--text-display: clamp(2rem, 1.36rem + 2.7vw, 4.5rem);
--leading-tight: 1.04;
--leading-heading: 1.15;
--leading-body: 1.7;
--tracking-display: -0.018em;
--tracking-heading: -0.012em;
--tracking-label: 0.08em;
--measure: 36rem;
--measure-narrow: 28rem;
--font-body: Pretendard, "Pretendard Variable", -apple-system, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
/* Light theme */
--page: #e9e7e2;
--surface: #f6f4ef;
--surface-muted: #d5d1ca;
--surface-quiet: #dfdcd6;
--ink: #151616;
--ink-muted: #5a5954;
--ink-soft: #6a6862;
--line: #b8b5ae;
--line-strong: #74736e;
--signal: #b9362b;
--signal-strong: #8f251d;
--signal-ink: #fff8f0;
--selection: #d9aaa1;
--focus: #8f251d;
/* ── 색 · 종이(라이트) ── */
--surface: #fafaf8;
--surface-2: #f0f0ec;
--surface-3: #e5e5e0;
--line: #dcdcd6;
--line-strong: #b9b9b2;
--ink: #111214; /* 17.9:1 */
--ink-muted: #54585c; /* 6.97:1 */
--ink-faint: #686d72; /* 5.03:1 */
--accent: #c4341f; /* 5.21:1 · 흰 글자 얹으면 5.44:1 */
--accent-hover: #9e2817;
--accent-ink: #ffffff;
--accent-wash: #f6e7e3;
/* Inverse surface */
--inverse: #111313;
--inverse-raised: #1a1d1c;
--inverse-ink: #f2f1ec;
--inverse-muted: #b4b5b0;
--inverse-line: #414442;
--inverse-signal: #ed6a58;
--inverse-signal-ink: #180b08;
/* ── 색 · 잉크(다크 반전 밴드) — 위 값들의 규칙적 반전 ── */
--dark-surface: #111214;
--dark-surface-2: #191b1e;
--dark-surface-3: #212428;
--dark-line: #2c3034;
--dark-line-strong: #40454a;
--dark-ink: #f2f2f0; /* 16.7:1 */
--dark-ink-muted: #a2a6a9; /* 7.67:1 */
--dark-ink-faint: #84898d; /* 5.31:1 */
--dark-accent: #e8563c; /* 5.17:1 */
--dark-accent-hover: #f2705a;
--dark-accent-ink: #160906;
--dark-accent-wash: #33170f;
/* 3D material roles */
--core-metal: #c9c6be;
--core-metal-dark: #343534;
--core-glass: #8c9898;
--core-spectrum: #4f7d88;
--core-shadow: #232423;
--core-light: #fffdf6;
--core-scrim: linear-gradient(90deg, #e9e7e2 0%, #e9e7e2e8 26%, #e9e7e200 58%);
--core-vignette: radial-gradient(circle at 74% 44%, #ffffff00 30%, #15161614 100%);
--shadow-core: 0 2rem 5rem #15161624;
/* ── 형태 — 현행 사이트가 radius 0 으로 통일돼 있다. 유지할 자산이다 ── */
--radius: 0;
/* ── 간격 — 저장소 SSOT 표준 ── */
/* Space · grid */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-3: 0.75rem;
--space-4: 1.25rem;
--space-5: 2rem;
--space-6: 3.5rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
--space-8: clamp(6rem, 4rem + 7vw, 10rem);
--space-9: clamp(8rem, 5rem + 10vw, 13rem);
--page-pad: clamp(1.25rem, 0.7rem + 2.4vw, 4rem);
--wide: 90rem;
--content: 75rem;
--header-h: 4.5rem;
--control-min: 2.75rem;
--hairline: 1px;
--focus-width: 2px;
--radius: 0px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
/* ── 모션 ── */
--dur-quick: 150ms;
--dur-normal: 380ms;
--ease: cubic-bezier(0.2, 0, 0, 1);
/* Motion */
--dur-instant: 120ms;
--dur-quick: 220ms;
--dur-normal: 420ms;
--dur-slow: 760ms;
--ease-out: cubic-bezier(0.22, 0.75, 0.16, 1);
--ease-in: cubic-bezier(0.55, 0.08, 0.68, 0.53);
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
--shift-sm: 0.5rem;
--shift-lg: 1.5rem;
--stagger: 60ms;
}
@media (prefers-color-scheme: dark) {
:root {
--page: #0d0f0f;
--surface: #171a1a;
--surface-muted: #2c302f;
--surface-quiet: #202423;
--ink: #f1f0eb;
--ink-muted: #b8bab6;
--ink-soft: #a8aaa7;
--line: #3b3e3d;
--line-strong: #6e7270;
--signal: #d99a38;
--signal-strong: #f0b454;
--signal-ink: #15120c;
--selection: #654a27;
--focus: #f0b454;
--inverse: #edeae4;
--inverse-raised: #f6f4ef;
--inverse-ink: #171817;
--inverse-muted: #5b5b57;
--inverse-line: #b9b5ae;
--inverse-signal: #a92f25;
--inverse-signal-ink: #fff8f0;
--core-metal: #555b5b;
--core-metal-dark: #090b0b;
--core-glass: #838d8b;
--core-spectrum: #7fb8c3;
--core-shadow: #030404;
--core-light: #f5ead3;
--core-scrim: linear-gradient(90deg, #0d0f0f 0%, #0d0f0fe8 26%, #0d0f0f00 58%);
--core-vignette: radial-gradient(circle at 74% 44%, #00000000 30%, #0000005c 100%);
--shadow-core: 0 2rem 5rem #00000066;
}
}
@media (prefers-reduced-motion: reduce) {
:root {
--shift-sm: 0px;
--shift-lg: 0px;
--stagger: 0ms;
--dur-normal: var(--dur-instant);
--dur-slow: var(--dur-instant);
--ease-out: ease-out;
--ease-in: ease-out;
--ease-soft: ease-out;
}
}

View file

@ -0,0 +1,229 @@
# 트웬티온스 전면 리디자인 — 1단계 레퍼런스 실측
> 상태: 인터뷰 선택 전에도 바뀌지 않는 구조·성능 근거를 먼저 확정한 기록.
> 색·명도·3D 주인공은 사용자 인터뷰 결과가 오면 2단계에서 최종 확정한다.
## 브리프
산업·교육 분야의 B2B 방문자가 트웬티온스의 네 현장과 실제 제품 관계를 공간적으로 이해하고 프로젝트 문의를 시작하게 한다. 느낌은 **정밀하고 몰입감 있게**.
### 0단계 기준선 감사
- 대상: `public/work/twentyoz-corp/index.html`과 쇼케이스 카드·캡처
- 현재 강점: 실제 고유명사와 기술 스택, 네 현장 라디오 전환, 의미 있는 HTML, 320/390/768/1440 오버플로·대비 통과
- 현재 약점: 첫 화면이 텍스트와 구분선만으로 구성돼 VR·산업 IoT·실시간 시뮬레이션을 시각적으로 증명하지 못함
- 모바일 약점: 390px에서 네 현장 선택기가 88px 높이로 네 줄 쌓이며, 선택 결과가 첫 화면 아래로 완전히 밀림
- 검증 공백: 기존 `tools/e2e.mjs`의 1,805개 단언은 `gaon-lms`와 `dure-enrollment`만 검사한다. 트웬티온스 전용 사용자 시나리오는 0개이므로 새 L6 스위트가 필요하다
## R1 · 구조 — Cognite
원본: https://www.cognite.com/en
- 업종: 산업 데이터·AI 플랫폼. 트웬티온스와 같은 산업 B2B 층위
- 가져올 축: **정보 위계, 그리드, 증거 배치**
- 섹션 흐름: 결과 중심 히어로 → 운영 성과 수치 → 고객 사례 → 역할별 효용 → 제품 체계 → 파트너 → 자료 → 데모 CTA
- 그리드: 1280px 뷰포트에서 지배 컨테이너 1233px, 주요 2열 589px / 565px. 본문 대표 폭 565px
- 타입: Space Grotesk + Manrope 2패밀리. 주요 h2 52/65px, 본문 18/32px. 최대/본문 2.9배
- 타입 단계 실측 상위: 12, 14, 16, 18, 20, 24, 28, 40, 44, 52px — 단계가 과다하므로 타입 체계는 가져오지 않는다
- 형태 실측: radius 2/4/6/8/12/9999px 등 6종 이상 — 형태 어휘도 가져오지 않는다
- 6축 밖의 관측: 기능 설명보다 운영 성과와 고객 증거가 먼저 온다. 데모·문의 CTA가 중간과 끝에서 반복된다
- 변형: 8단계 흐름을 `현장 → 제품 → 검증된 일 → 회사 → 문의` 5단계로 압축한다. 트웬티온스는 Cognite보다 제품·사례 수가 적고, 현재 8개 섹션이 이미 긴 페이지를 만든다
## R2 · 톤 — Polestar
원본: https://www.polestar.com/global/
- 업종: 전기차·자동차 커머스. R1과 다른 업종 ✓
- 가져올 축: **시각적 무게, 컬러 역할, 형태 절제, 타입 밀도**
- 첫 화면: 1280×720 중 제품 이미지의 실제 가시 면적 88.9%. 이미지 1265×648, y=72부터 시작
- 텍스트 위치: x=112, y=152. h1 30px / 400 / letter-spacing -1.2px. 제품이 크면 타이포가 물러난다
- CTA: 132×48px, radius 0. 제품 이미지 위에 있으나 단색·직사각형으로 조용하다
- 타입: Polestar Unica 한 패밀리. 지배 단계 16px / 30px, 장식적 굵기 대비 없이 400 중심
- 컬러: 검정 텍스트 82회, 60% 검정 보조 7회, 흰 텍스트 1회. 웹 UI의 강조색은 사실상 없고 제품 이미지가 색을 소유한다
- 형태: radius 사용 0건. 카드·필·글래스 패널로 고급감을 흉내내지 않는다
- 6축 밖의 관측: 제품 비주얼이 주인공이고 UI는 설명서처럼 물러난다
- 변형: 실제 자동차 사진 대신 네 현장 코어 3D가 첫 화면 가시 면적의 72~82%를 차지하게 한다. 국문 정보량 때문에 본문은 18px, h1은 최대 64px까지 올리되 2줄을 넘기지 않는다
## R3 · 디테일 — Shader.se 선택 렌더링과 장면 전환
원본: https://tympanus.net/codrops/2026/05/19/80s-business-tech-seamless-scene-transitions-inside-shader-ses-scroll-driven-webgpu-pipeline/
- 가져올 축: **3D 장면 전환과 렌더링 예산**
- 모션: 스크롤 진행도를 0..1로 정규화하고 현재 범위 밖 장면은 render pass 전체를 건너뛴다. 전환 직전 다음 장면만 미리 렌더해 텍스처를 현재 장면에 전달한다
- 전환 기법: 화면 좌표로 다음 장면 FBO를 샘플링하는 2D reveal, 또는 실제 3D 표면에 맞춰 카메라 frustum을 이동하는 물리적 전환
- 제약: 원본은 Three.js + React Three Fiber + TSL + WebGPU/WebGL + Lenis + canvas UI를 사용한다. 다중 FBO와 포스트프로세싱은 GPU 비용이 크며 canvas UI는 DOM 접근성을 잃는다
- 원본 스스로 지적한 한계: HTML을 캔버스 안에 다시 만들면 레이아웃·텍스트·입력·접근성을 브라우저 수준으로 재현하기 어렵다
- 변형: 기존 Astro + vanilla Three.js를 유지한다. 현재 장면과 다음 장면만, half-resolution FBO 최대 2장만 사용한다. UI와 카피는 DOM이 소유하고 WebGL은 조형물·상태 uniform만 소유한다. Lenis·R3F·uikit·WebGPU 전용 경로는 추가하지 않는다
## 합성
- 구조와 증거 순서: R1 Cognite
- 첫 화면의 시각적 무게·무장식 형태·타입 절제: R2 Polestar
- 네 현장 전환의 렌더링 문법: R3 Shader.se 선택 렌더링
- 고유 결과: 자동차 사진도 산업 SaaS 카드도 아닌, **네 현장이 하나의 코어에서 다른 작동 상태로 바뀌는 실시간 산업 조형물**
## 회색조 위계
색을 전부 제거했을 때 가장 강한 덩어리는 **네 현장 3D 코어**, 두 번째는 **한 문장 가치 제안**, 세 번째는 **현재 현장 선택과 프로젝트 문의**다.
비즈니스 우선순위 `[1 기술을 체감 / 2 어느 현장에 쓰이는지 이해 / 3 문의]`와 일치한다.
## 안 할 것
- 보라·파랑 AI 그라디언트와 빛나는 유리 카드로 기술을 표현하지 않는다
- 네 제품을 같은 크기 카드 네 장으로 반복하지 않는다
- 장식용 입자·마퀴·스크롤 가로막기·커스텀 스크롤을 넣지 않는다
- 제품 UI와 카피를 캔버스 안에 다시 만들지 않는다
- 실제 출처를 다시 검증하지 않은 수치·수상·파트너 로고를 추가하지 않는다
- 3D가 꺼졌을 때 빈 검정 사각형이 남지 않게 정적 렌더와 완성된 DOM 레이아웃을 함께 만든다
## 1단계 통과 판정
- R1/R2/R3 원본 URL: 있음
- R2 다른 업종: 확인
- 각 슬롯의 축과 변형: 기록
- 그리드·타입·면적·진행도 수치: 실제 브라우저 computed style/DOM rect로 측정
- R3 지원 범위·비용·접근성 제약: 기록
- 회색조 위계와 비즈니스 우선순위: 일치
## 2단계 · 콘셉트와 프리셋
### 한 문장 콘셉트
**정밀한 공간 계기 — 네 산업 현장이 하나의 코어 안에서 다른 물리 상태로 전환된다.**
### 커스텀 프리셋 `precision-spatial`
- 기반 문법: `swiss-minimal`의 단일 그로테스크, 중립색 90%, 엄격한 정렬, 평면 DOM UI
- 추가 문법: 첫 화면 한 곳에만 실시간 3D 코어를 두고, 나머지 섹션은 선·여백·타이포로 조용하게 유지
- 금지: 글래스 패널, 같은 크기 카드 반복, 둥근 알약형 버튼, 장식용 그라디언트, 무한 마퀴, 스크롤 가로막기
- 형태: DOM은 직각, 3D 조형물만 원형·곡면을 소유한다
- 의도적 위험: 3D 조형물이 히어로 가시 면적의 72~82%를 차지한다. 위험은 한 곳에 집중하고 카피·CTA는 네이티브 DOM으로 고정한다
### 선택한 시각안
- 기본 테마: 따뜻한 백색 스튜디오, 세라믹·티타늄 재질, 단일 적색 신호
- 어두운 시스템 테마: 흑연·건메탈 재질, 동일한 신호 역할의 호박색 대응값
- 정적 렌더: AI 이미지 생성 결과를 WebGL 불가·감소 모션·초기 로딩 폴백으로만 사용
- 실시간 장면: 네 현장 선택이 코어의 회전축, 층 간격, 신호 주기, 표면 거칠기를 바꾼다
- UI 역할: 선택·텍스트·링크·표는 HTML이 소유하고 캔버스는 의미 없는 장식으로 선언한다
### 정보 구조
1. 현장 — 가치 제안, 네 현장 선택, 대응 제품, 문의 CTA
2. 제품 — XD Hub / XD Works / XD Builder / XD Play의 역할과 기술
3. 검증된 일 — 기존 12개 프로젝트 기록
4. 회사 — 기존 수치·파트너·수상·채용 기록
5. 문의 — 이메일 CTA와 회사 정보
## 2단계 통과 판정
- 한 문장 콘셉트: 있음
- 프리셋: `precision-spatial`로 명명하고 차용·변형·금지 규칙 기록
- 와우 포인트: 첫 화면 3D 코어 한 곳
- 의도적 위험: 범위와 폴백까지 기록
- 기존 기능·정보: 네 현장 상태와 모든 공개 기록을 유지
## 3단계 · 토큰과 성능 예산
### 색 역할
| 역할 | 밝은 테마 | 어두운 테마 |
|---|---:|---:|
| page | `#E9E7E2` | `#0D0F0F` |
| surface | `#F6F4EF` | `#171A1A` |
| ink | `#151616` | `#F1F0EB` |
| muted | `#5A5954` | `#A8AAA7` |
| line | `#B8B5AE` | `#3B3E3D` |
| signal | `#B9362B` | `#D99A38` |
| signal ink | `#FFF8F0` | `#15120C` |
- 강조색은 테마별 한 역할 `signal`만 사용한다
- 링크·포커스·선택 상태는 색만이 아니라 밑줄, 선 두께, 텍스트 상태를 함께 바꾼다
### 타입
- 한 패밀리: `Pretendard Variable` + 시스템 폴백
- 본문: 17~18px, line-height 1.7, letter-spacing 0
- 소형 레이블: 12~13px, line-height 1.4, 숫자는 tabular-nums
- h1: `clamp(3rem, 1.9rem + 3.2vw, 4.5rem)`, line-height 1.04, letter-spacing `-0.018em`, 최대 2줄
- h2: `clamp(2rem, 1.75rem + 0.8vw, 2.5rem)`, line-height 1.15, letter-spacing `-0.012em`
- 한글 본문: `word-break: keep-all`, `overflow-wrap: break-word`, 25~40자 폭
### 공간·형태·모션
- 공간 기본 단위: 4px
- 주요 단계: 8 / 12 / 20 / 32 / 56 / 96 / 160px
- DOM radius: 0px. 원형은 3D 조형물과 상태 점에만 허용
- 컨트롤 최소 높이: 44px
- 지속시간: 120 / 220 / 420 / 760ms
- 이징: `cubic-bezier(.22, .75, .16, 1)`
- `prefers-reduced-motion`에서는 자동 회전·보간·스크롤 등장 효과를 중지하고 정적 렌더를 유지
### 성능 예산
- 히어로 자체 JS + 셰이더: gzip 35KB 이하
- WebGL 관련 추가 전송: gzip 200KB 이하
- 첫 화면 전체 전송: 1MB 이하
- 정적 폴백 이미지: 테마별 350KB 이하, 명시적 크기로 CLS 방지
- 첫 상호작용 가능: 3초 이하
- LCP: 2.5초 이하, CLS: 0.1 이하
- DPR: 데스크톱 최대 1.75, 모바일 최대 1.35
- 렌더 루프: 문서 비가시·히어로 화면 밖·감소 모션에서 중지
## 3단계 통과 판정
- 색·타입·공간·형태·모션 역할 토큰 정의
- 단일 서체와 단일 신호색으로 시각 문법 제한
- 3D·이미지·사용자 체감 성능 예산을 수치로 고정
- 감소 모션·WebGL 실패·모바일 DPR 폴백을 구현 전 요구사항으로 승격
## 4단계 · 구현과 콘텐츠 대응
### 공식 사이트 대응
- 회사 공개 수치: 159 projects, 42 clients, 40 crew, 4 patents
- 제품 체계: VIVEN, EduBill, XD Hub, XD Works
- 공개 작업: 49건 중 현재 홈페이지가 노출하는 12건을 이름·연도·기술 축으로 유지
- 운영 원칙: Hybrid, Continuum, Precision, Empowerment를 한국어 공식 문체로 재구성
- 최근 기록: `https://twentyoz.kr/news/111`의 2026-02-25 K기술대상 산업대상을 최상단에 추가
- 상세 페이지: 제품별 핵심 기능 4개, 기술 스택, 운영 사양, 4단계 운용 흐름, 도입 문의를 별도 실제 경로에 구현
### 3D 모델 조사와 최적화
- 선택 모델: `Scifi Drone 1.1` by Javier Martín Hidalgo
- 원본: `https://sketchfab.com/3d-models/scifi-drone-11-dd21953e81a24bd385d16eaf0c6a8ae7`
- 라이선스: CC BY 4.0. 화면 푸터와 `assets/twentyoz-field-core-LICENSE.txt`에 귀속을 남김
- 원본 ZIP 8.37MB를 glTF Transform으로 양자화·WebP 텍스처 최적화하여 최종 GLB 697,076B로 축소
- 원본 유휴 애니메이션은 코어가 화면을 배회해 정보 위계를 무너뜨리므로 재생하지 않고, 카메라 안에서 제한된 절차적 회전만 적용
### 셰이더·후처리·형태 보강
- RoomEnvironment 기반 PBR 조명, EffectComposer, 제한적 UnrealBloom, OutputPass 구성
- 마지막 합성 패스에서 미세 색수차·비네트·그레인을 적용하되 카피 대비에 영향을 주지 않음
- 절단된 오픈 링의 단면에는 cylinder end-cap 메시를 추가해 빈 내부가 보이지 않게 함
- 모델을 전면으로 이동하고 호박색 신호 링을 뒤로 분리함
- 청록색의 더 크고 얇은 스펙트럼 링을 한 겹 더 추가해 `data-glow-rings="2"`로 런타임 검증
## 5단계 · 브라우저 검증
- 전용 E2E: 122/122 assertions GREEN
- 반응형: 320 / 390 / 768 / 1440, 가로 오버플로 0
- 실제 WebGL: 모델 준비, postprocess, shader stack, 두 개 glow ring, 상태 전환 확인
- 접근성: 메인·제품 상세 라이트/다크 axe 위반 0
- 폴백: WebGL 비활성, 감소 모션, 모듈 실패에서 정적 렌더와 DOM 유지
- 성능: 코어 번들 646,152B raw / 166,057B gzip, 200KB 예산 통과
- 제품 상세: 320 / 1440 반응형·링크·스펙·axe 검증
## 6단계 · 셀프 감사와 게이트
- `integrity.mjs`: 114/114 통과
- `showcase-audit.mjs twentyoz-corp`: 320/390/768/1440 4/4 통과
- 메인 design gate: 메타, 죽은 선택자, 토큰 위생, 오버플로, h1, 수축, 리듬, 대비, 글꼴 확대, 터치, 인셋 통과
- 제품 design gate: 동일 실렌더 항목 통과. 공통 `main.css`의 죽은 선택자는 메인 게이트가 소유해 페이지 단독 오탐을 피함
- 전체 Astro build 통과
- 전체 `verify`에 트웬티온스 3D 자산, 제품 HTML, 메인·제품 design gate, 전용 L6 E2E를 편입
## 최종 판정
공식 카피와 실제 제품 구조, 라이선스가 명시된 최적화 3D, 이중 깊이 링, 셰이더·후처리, 상세 제품 경로, 폴백·접근성·성능·E2E 게이트가 하나의 배포 기준으로 연결되었다.

View file

@ -0,0 +1,660 @@
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
const STATES = [
{
name: "edu",
speed: 0.16,
spread: 1,
group: [0.08, -0.12, -0.06],
rings: [
[0.42, -0.18, 0.08],
[1.18, 0.26, -0.2],
[0.18, 0.78, 0.4],
[1.36, -0.46, -0.08],
[0.78, 0.54, -0.72],
],
},
{
name: "dc",
speed: 0.1,
spread: 1.08,
group: [-0.04, 0.12, 0.02],
rings: [
[Math.PI / 2, 0, 0],
[0, Math.PI / 2, 0],
[Math.PI / 2, Math.PI / 2, 0],
[0.38, -0.62, Math.PI / 2],
[1.12, 0.44, -0.36],
],
},
{
name: "gov",
speed: 0.065,
spread: 0.92,
group: [0.02, -0.04, 0],
rings: [
[0.24, 0.08, 0],
[0.42, 0.12, 0.08],
[0.58, 0.18, 0.16],
[0.74, 0.24, 0.24],
[0.34, -0.52, 0.68],
],
},
{
name: "plant",
speed: 0.2,
spread: 1.14,
group: [0.14, 0.16, -0.04],
rings: [
[0.72, 0.52, -0.18],
[1.42, -0.34, 0.34],
[0.2, 1.12, -0.46],
[1.08, 0.18, Math.PI / 2],
[1.38, -0.62, -0.84],
],
},
];
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const damp = (from, to, lambda, delta) => THREE.MathUtils.lerp(from, to, 1 - Math.exp(-lambda * delta));
function durationInSeconds(css, token, fallback) {
const value = css.getPropertyValue(token).trim();
if (!value) return fallback;
if (value.endsWith("ms")) return Number.parseFloat(value) / 1000;
if (value.endsWith("s")) return Number.parseFloat(value);
return fallback;
}
function tokenColor(css, token) {
const value = css.getPropertyValue(token).trim();
if (!value) throw new Error(`Missing design token: ${token}`);
return new THREE.Color(value);
}
function createRing(radius, tube, arc, material) {
const geometry = new THREE.TorusGeometry(radius, tube, 16, 96, arc);
const ring = new THREE.Group();
ring.add(new THREE.Mesh(geometry, material));
if (arc < Math.PI * 1.99) {
const terminalLength = tube * 1.75;
const terminalGeometry = new THREE.CylinderGeometry(tube * 1.18, tube * 1.08, terminalLength, 20, 1, false);
const yAxis = new THREE.Vector3(0, 1, 0);
[
{ angle: 0, direction: -1 },
{ angle: arc, direction: 1 },
].forEach(({ angle, direction }) => {
const tangent = new THREE.Vector3(-Math.sin(angle), Math.cos(angle), 0).multiplyScalar(direction);
const terminal = new THREE.Mesh(terminalGeometry, material);
terminal.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, 0);
terminal.position.addScaledVector(tangent, terminalLength * 0.32);
terminal.quaternion.setFromUnitVectors(yAxis, tangent);
ring.add(terminal);
});
}
ring.userData.baseScale = 1;
return ring;
}
export function mount({ canvas, hero, toggle, initialState = 0 }) {
const css = getComputedStyle(document.documentElement);
const colors = {
page: tokenColor(css, "--page"),
surface: tokenColor(css, "--surface"),
metal: tokenColor(css, "--core-metal"),
metalDark: tokenColor(css, "--core-metal-dark"),
glass: tokenColor(css, "--core-glass"),
spectrum: tokenColor(css, "--core-spectrum"),
shadow: tokenColor(css, "--core-shadow"),
light: tokenColor(css, "--core-light"),
signal: tokenColor(css, "--signal"),
line: tokenColor(css, "--line"),
};
const transitionSeconds = durationInSeconds(css, "--dur-normal", 0.42);
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: false,
antialias: false,
depth: true,
stencil: false,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.98;
renderer.setClearColor(colors.page, 1);
const scene = new THREE.Scene();
scene.background = colors.page;
scene.fog = new THREE.Fog(colors.page, 7.2, 12.5);
const roomEnvironment = new RoomEnvironment();
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTarget = pmremGenerator.fromScene(roomEnvironment, 0.04);
scene.environment = environmentTarget.texture;
roomEnvironment.dispose();
pmremGenerator.dispose();
const camera = new THREE.PerspectiveCamera(31, 1, 0.1, 40);
camera.position.set(0, 0.1, 7.2);
const finishShader = {
uniforms: {
tDiffuse: { value: null },
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(1, 1) },
uAmount: { value: 1 },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform float uTime;
uniform vec2 uResolution;
uniform float uAmount;
varying vec2 vUv;
float hash21(vec2 point) {
point = fract(point * vec2(123.34, 456.21));
point += dot(point, point + 45.32);
return fract(point.x * point.y);
}
void main() {
vec2 centered = vUv - 0.5;
float edge = smoothstep(0.12, 0.72, length(centered * vec2(0.82, 1.0)));
vec2 direction = normalize(centered + vec2(0.0001));
vec2 aberration = direction * 0.0007 * uAmount;
vec4 base = texture2D(tDiffuse, vUv);
float red = texture2D(tDiffuse, vUv + aberration).r;
float blue = texture2D(tDiffuse, vUv - aberration).b;
float grain = hash21(gl_FragCoord.xy + floor(uTime * 18.0)) - 0.5;
vec3 color = vec3(red, base.g, blue);
color *= 1.0 - edge * 0.08 * uAmount;
color += grain * 0.007 * uAmount;
gl_FragColor = vec4(color, base.a);
}
`,
};
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.13, 0.24, 0.96);
const finishPass = new ShaderPass(finishShader);
const outputPass = new OutputPass();
composer.addPass(renderPass);
composer.addPass(bloomPass);
composer.addPass(finishPass);
composer.addPass(outputPass);
renderer.info.autoReset = false;
canvas.dataset.effects = "field-shader bloom chromatic-aberration vignette grain";
const metal = new THREE.MeshStandardMaterial({
color: colors.metal,
metalness: 0.88,
roughness: 0.4,
envMapIntensity: 0.3,
});
const darkMetal = new THREE.MeshStandardMaterial({
color: colors.metalDark,
metalness: 0.82,
roughness: 0.34,
envMapIntensity: 0.34,
});
const glass = new THREE.MeshStandardMaterial({
color: colors.glass,
metalness: 0.36,
roughness: 0.18,
transparent: true,
opacity: 0.48,
envMapIntensity: 0.28,
});
const signal = new THREE.MeshStandardMaterial({
color: colors.signal,
emissive: colors.signal,
emissiveIntensity: 2.1,
metalness: 0.28,
roughness: 0.2,
envMapIntensity: 0.7,
});
const spectrum = new THREE.MeshStandardMaterial({
color: colors.spectrum,
emissive: colors.spectrum,
emissiveIntensity: 0.96,
metalness: 0.22,
roughness: 0.24,
envMapIntensity: 0.6,
});
const core = new THREE.Group();
scene.add(core);
const modelRig = new THREE.Group();
core.add(modelRig);
const ringMeshes = [
createRing(1.66, 0.078, Math.PI * 1.78, metal),
createRing(1.46, 0.075, Math.PI * 2, darkMetal),
createRing(1.14, 0.06, Math.PI * 1.72, glass),
createRing(1.08, 0.032, Math.PI * 2, signal),
createRing(1.44, 0.016, Math.PI * 2, spectrum),
];
ringMeshes[3].position.set(0.12, -0.06, -0.48);
ringMeshes[4].position.set(-0.06, 0.1, -0.64);
canvas.dataset.glowRings = "2";
ringMeshes.forEach((ring) => core.add(ring));
const shell = new THREE.Mesh(new THREE.IcosahedronGeometry(0.58, 3), glass);
const innerCore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.23, 3), signal);
core.add(shell, innerCore);
const crossMaterial = new THREE.MeshStandardMaterial({
color: colors.metalDark,
metalness: 0.9,
roughness: 0.4,
});
const crossHorizontal = new THREE.Mesh(new THREE.BoxGeometry(2.25, 0.032, 0.055), crossMaterial);
const crossVertical = new THREE.Mesh(new THREE.BoxGeometry(0.032, 2.25, 0.055), crossMaterial);
crossHorizontal.position.z = -0.08;
crossVertical.position.z = -0.08;
core.add(crossHorizontal, crossVertical);
const signalBead = new THREE.Mesh(new THREE.SphereGeometry(0.085, 16, 12), signal);
core.add(signalBead);
const fieldUniforms = {
uTime: { value: 0 },
uSignal: { value: colors.signal.clone() },
uOpacity: { value: 0.14 },
};
const fieldShell = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.16, 4),
new THREE.ShaderMaterial({
uniforms: fieldUniforms,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
wireframe: true,
vertexShader: /* glsl */ `
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
varying vec3 vObjectPosition;
void main() {
vWorldNormal = normalize(mat3(modelMatrix) * normal);
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vObjectPosition = position;
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`,
fragmentShader: /* glsl */ `
uniform float uTime;
uniform vec3 uSignal;
uniform float uOpacity;
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
varying vec3 vObjectPosition;
void main() {
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
float fresnel = pow(1.0 - abs(dot(normalize(vWorldNormal), viewDirection)), 3.2);
float scan = 0.5 + 0.5 * sin(vObjectPosition.y * 54.0 - uTime * 1.6);
float latitude = smoothstep(0.96, 0.42, abs(vObjectPosition.y));
float alpha = fresnel * (0.62 + scan * 0.38) * latitude * uOpacity;
gl_FragColor = vec4(uSignal * (1.05 + fresnel * 0.65), alpha);
}
`,
}),
);
fieldShell.scale.set(1.18, 0.78, 0.86);
fieldShell.rotation.set(0.12, -0.18, 0.08);
modelRig.add(fieldShell);
let modelMixer = null;
let fieldModel = null;
canvas.dataset.model = "loading";
const modelUrl = new URL("./assets/twentyoz-field-core.glb", document.baseURI).href;
new GLTFLoader().load(
modelUrl,
(gltf) => {
if (disposed) return;
const sourceModel = gltf.scene;
const bounds = new THREE.Box3().setFromObject(sourceModel);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const modelScale = 2.34 / Math.max(size.x, size.y, size.z, 1);
sourceModel.position.sub(center);
fieldModel = new THREE.Group();
fieldModel.add(sourceModel);
fieldModel.scale.setScalar(modelScale);
fieldModel.rotation.set(-0.04, Math.PI, -0.02);
sourceModel.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = false;
object.receiveShadow = false;
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
material.emissive?.copy(colors.signal).multiplyScalar(0.54);
material.emissiveIntensity = 1.08;
material.envMapIntensity = 1.15;
material.roughness = Math.max(material.roughness ?? 0.3, 0.24);
material.metalness = Math.max(material.metalness ?? 0.7, 0.62);
material.needsUpdate = true;
});
});
modelRig.add(fieldModel);
modelRig.position.set(-0.14, 0, 0.46);
shell.visible = false;
innerCore.visible = false;
crossHorizontal.visible = false;
crossVertical.visible = false;
ringMeshes[1].visible = false;
ringMeshes[2].visible = false;
canvas.dataset.model = "ready";
hero.dataset.model = "ready";
renderer.compile(scene, camera);
transitionUntil = performance.now() + transitionSeconds * 1000;
syncLoop();
},
undefined,
() => {
canvas.dataset.model = "fallback";
hero.dataset.model = "fallback";
},
);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(18, 12),
new THREE.MeshStandardMaterial({ color: colors.surface, metalness: 0, roughness: 0.96 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -2.05;
floor.position.z = -0.8;
scene.add(floor);
const contactShadow = new THREE.Mesh(
new THREE.CircleGeometry(2.2, 48),
new THREE.MeshBasicMaterial({ color: colors.shadow, transparent: true, opacity: 0.14, depthWrite: false }),
);
contactShadow.rotation.x = -Math.PI / 2;
contactShadow.scale.set(1.35, 0.42, 1);
contactShadow.position.set(0, -2.025, 0);
scene.add(contactShadow);
scene.add(new THREE.HemisphereLight(colors.light, colors.shadow, 2.15));
const keyLight = new THREE.DirectionalLight(colors.light, 5.2);
keyLight.position.set(-4.5, 5.2, 5.8);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(colors.signal, 2.4);
rimLight.position.set(5.5, 1.8, 2.2);
scene.add(rimLight);
const fillLight = new THREE.DirectionalLight(colors.metal, 1.65);
fillLight.position.set(1.5, -2, 4);
scene.add(fillLight);
const pointer = new THREE.Vector2();
const pointerTarget = new THREE.Vector2();
let selected = clamp(initialState, 0, STATES.length - 1);
let elapsed = 0;
let lastFrame = performance.now();
let transitionUntil = lastFrame;
let paused = navigator.webdriver === true;
let visible = true;
let disposed = false;
let dprLimit = 1.75;
let runtimeDprLimit = Number.POSITIVE_INFINITY;
let frameInterval = 1000 / 45;
let fpsWindow = 0;
let fpsFrames = 0;
let fpsDrops = 0;
const coreScreenPosition = new THREE.Vector3();
// Keep the hero composition intentional at each CSS layout tier. The canvas
// can be portrait-shaped on tablets, so placement follows width rather than
// the performance-oriented `narrow` flag below.
const coreLayouts = [
{ name: "mobile", maxWidth: 688, x: 0, y: -0.08, z: 7.8, scale: 0.86 },
{ name: "tablet", maxWidth: 992, x: 0.9, y: -0.08, z: 8.05, scale: 0.88 },
{ name: "compact", maxWidth: 1279, x: 0.75, y: -0.1, z: 8.3, scale: 0.92 },
{ name: "wide", maxWidth: Number.POSITIVE_INFINITY, x: 0.72, y: -0.12, z: 8.4, scale: 0.94 },
];
function fit() {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
const narrow = width / height < 0.95 || width < 760;
const layout = coreLayouts.find((candidate) => width <= candidate.maxWidth) || coreLayouts.at(-1);
dprLimit = Math.min(narrow ? 1.35 : 1.75, runtimeDprLimit);
frameInterval = 1000 / (narrow ? 30 : 45);
const dpr = Math.min(window.devicePixelRatio || 1, dprLimit);
renderer.setPixelRatio(dpr);
renderer.setSize(width, height, false);
composer.setPixelRatio(Math.min(dpr, narrow ? 1 : 1.35));
composer.setSize(width, height);
finishPass.uniforms.uResolution.value.set(width * dpr, height * dpr);
finishPass.uniforms.uAmount.value = narrow ? 0.68 : 1;
bloomPass.strength = narrow ? 0.08 : 0.13;
bloomPass.radius = narrow ? 0.18 : 0.24;
camera.aspect = width / height;
camera.position.z = layout.z;
camera.updateProjectionMatrix();
core.position.set(layout.x, layout.y, 0);
core.scale.setScalar(layout.scale);
floor.position.x = core.position.x;
contactShadow.position.x = core.position.x;
scene.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
core.getWorldPosition(coreScreenPosition).project(camera);
canvas.dataset.dpr = dpr.toFixed(2);
canvas.dataset.layoutTier = layout.name;
canvas.dataset.coreOffsetX = layout.x.toFixed(2);
canvas.dataset.coreScreenRatio = (coreScreenPosition.x * 0.5 + 0.5).toFixed(3);
renderFrame(0);
}
function setState(index) {
selected = clamp(index, 0, STATES.length - 1);
transitionUntil = performance.now() + transitionSeconds * 1000;
canvas.dataset.coreState = STATES[selected].name;
syncLoop();
}
function onPointerMove(event) {
const rect = hero.getBoundingClientRect();
pointerTarget.set(
clamp(((event.clientX - rect.left) / Math.max(rect.width, 1) - 0.5) * 2, -1, 1),
clamp(((event.clientY - rect.top) / Math.max(rect.height, 1) - 0.5) * 2, -1, 1),
);
if (paused) {
transitionUntil = performance.now() + transitionSeconds * 1000;
syncLoop();
}
}
function onPointerLeave() {
pointerTarget.set(0, 0);
if (paused) {
transitionUntil = performance.now() + transitionSeconds * 1000;
syncLoop();
}
}
function render(now) {
if (now - lastFrame < frameInterval) return;
const delta = Math.min((now - lastFrame) / 1000, 1 / 20);
lastFrame = now;
if (!paused) elapsed += delta;
const preset = STATES[selected];
pointer.x = damp(pointer.x, pointerTarget.x, 4.8, delta);
pointer.y = damp(pointer.y, pointerTarget.y, 4.8, delta);
core.rotation.x = damp(core.rotation.x, preset.group[0] - pointer.y * 0.08, 5.5, delta);
core.rotation.y = damp(core.rotation.y, preset.group[1] + pointer.x * 0.12, 5.5, delta);
core.rotation.z = damp(core.rotation.z, preset.group[2], 5.5, delta);
ringMeshes.forEach((ring, index) => {
const target = preset.rings[index];
const drift = paused ? 0 : elapsed * preset.speed * (index % 2 ? -1 : 1);
ring.rotation.x = damp(ring.rotation.x, target[0], 5.2, delta);
ring.rotation.y = damp(ring.rotation.y, target[1], 5.2, delta);
ring.rotation.z = damp(ring.rotation.z, target[2] + drift, 5.2, delta);
const scale = preset.spread * (1 - index * 0.018);
ring.scale.setScalar(damp(ring.scale.x, scale, 5.2, delta));
});
const orbit = elapsed * (preset.speed * 1.8) + selected * 0.72;
signalBead.position.set(Math.cos(orbit) * 1.78, Math.sin(orbit) * 1.78, 0.12);
const pulse = paused ? 1 : 1 + Math.sin(elapsed * 2.2) * 0.055;
innerCore.scale.setScalar(pulse);
signal.emissiveIntensity = 0.92 + (paused ? 0 : Math.sin(elapsed * 1.7) * 0.1);
spectrum.emissiveIntensity = 0.9 + (paused ? 0 : Math.sin(elapsed * 1.14 + 1.2) * 0.08);
fieldUniforms.uTime.value = elapsed;
fieldUniforms.uOpacity.value = 0.1 + selected * 0.012;
finishPass.uniforms.uTime.value = elapsed;
if (modelMixer && !paused) modelMixer.update(delta);
if (fieldModel) {
modelRig.rotation.y = damp(modelRig.rotation.y, pointer.x * 0.12, 4.2, delta);
modelRig.rotation.x = damp(modelRig.rotation.x, pointer.y * -0.08, 4.2, delta);
const modelPulse = paused ? 1 : 1 + Math.sin(elapsed * 0.72) * 0.012;
modelRig.scale.setScalar(modelPulse);
}
renderFrame(delta);
canvas.dataset.drawCalls = String(renderer.info.render.calls);
fpsWindow += delta;
fpsFrames += 1;
if (delta > 1 / 30) fpsDrops += 1;
if (fpsWindow >= 4) {
if (fpsDrops / Math.max(fpsFrames, 1) > 0.3 && dprLimit > 1) {
runtimeDprLimit = 1;
fit();
}
fpsWindow = 0;
fpsFrames = 0;
fpsDrops = 0;
}
if (paused && now >= transitionUntil) syncLoop();
}
function shouldRun() {
return !disposed && visible && !document.hidden && (!paused || performance.now() < transitionUntil);
}
function renderFrame(delta = 0) {
renderer.info.reset();
composer.render(delta);
}
function syncLoop() {
renderer.setAnimationLoop(shouldRun() ? render : null);
if (!shouldRun()) renderFrame(0);
}
function onToggle() {
paused = !paused;
toggle.setAttribute("aria-pressed", String(paused));
toggle.textContent = paused ? "3D 모션 켜기" : "3D 모션 끄기";
if (!paused) lastFrame = performance.now();
syncLoop();
}
function onVisibility() {
lastFrame = performance.now();
syncLoop();
}
function onContextLost(event) {
event.preventDefault();
hero.dataset.webgl = "fallback";
hero.dataset.webglReason = "context-lost";
toggle.hidden = true;
dispose();
}
const resizeObserver = new ResizeObserver(fit);
resizeObserver.observe(canvas);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
visible = entry.isIntersecting;
lastFrame = performance.now();
syncLoop();
},
{ rootMargin: "10%" },
);
intersectionObserver.observe(hero);
hero.addEventListener("pointermove", onPointerMove, { passive: true });
hero.addEventListener("pointerleave", onPointerLeave);
toggle.addEventListener("click", onToggle);
document.addEventListener("visibilitychange", onVisibility);
canvas.addEventListener("webglcontextlost", onContextLost, false);
fit();
if (paused) {
toggle.setAttribute("aria-pressed", "true");
toggle.textContent = "3D 모션 켜기";
}
setState(selected);
renderer.compile(scene, camera);
renderFrame(0);
hero.dataset.webgl = "ready";
toggle.hidden = false;
syncLoop();
function dispose() {
if (disposed) return;
disposed = true;
renderer.setAnimationLoop(null);
resizeObserver.disconnect();
intersectionObserver.disconnect();
hero.removeEventListener("pointermove", onPointerMove);
hero.removeEventListener("pointerleave", onPointerLeave);
toggle.removeEventListener("click", onToggle);
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("webglcontextlost", onContextLost);
scene.traverse((object) => {
object.geometry?.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
Object.values(material).filter((value) => value?.isTexture).forEach((texture) => texture.dispose());
material.dispose();
});
});
scene.clear();
environmentTarget.dispose();
composer.dispose();
bloomPass.dispose();
finishPass.dispose();
outputPass.dispose();
renderer.dispose();
renderer.forceContextLoss();
}
return { setState, dispose };
}

View file

@ -0,0 +1,48 @@
import { build } from "esbuild";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import zlib from "node:zlib";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const entry = path.join(root, "src", "showcases", "twentyoz-core.js");
const outDir = path.join(root, "public", "work", "twentyoz-corp", "assets");
const outputFile = path.join(outDir, "core.iife.js");
const check = process.argv.includes("--check");
const result = await build({
absWorkingDir: root,
entryPoints: [entry],
outfile: outputFile,
bundle: true,
platform: "browser",
format: "iife",
globalName: "TwentyozCore",
target: ["es2020"],
minify: true,
treeShaking: true,
legalComments: "eof",
sourcemap: false,
write: false,
});
const output = result.outputFiles?.find((item) => path.resolve(item.path) === path.resolve(outputFile));
if (!output) throw new Error("twentyoz core bundle output not found");
const generated = Buffer.from(output.contents);
const gzipBytes = zlib.gzipSync(generated, { level: 9 }).byteLength;
const maxGzipBytes = 200 * 1024;
if (gzipBytes > maxGzipBytes) {
throw new Error(`twentyoz WebGL bundle ${gzipBytes}B gzip exceeds ${maxGzipBytes}B budget`);
}
if (check) {
if (!fs.existsSync(outputFile)) throw new Error("twentyoz core bundle is missing; run npm run build:showcase-assets");
const current = fs.readFileSync(outputFile);
if (!current.equals(generated)) throw new Error("twentyoz core bundle is stale; run npm run build:showcase-assets");
} else {
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outputFile, generated);
}
console.log(`twentyoz core ${generated.byteLength}B raw · ${gzipBytes}B gzip · budget ${maxGzipBytes}B`);

View file

@ -0,0 +1,517 @@
#!/usr/bin/env node
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
//
// 조사 기반 7계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션 + 죽은 선택자·토큰 위생) + L2(불변식·SEO/meta·theme-color 정합·터치타깃) + L3(시각회귀·옵션) + L4(WebKit·옵션).
// 프로젝트 고유의 L1(단위)·L5(탐색)·L6(시나리오 E2E)는 각 프로젝트의 calc/테스트로 보강한다.
//
// 사용:
// node design-gate.mjs --init # 설정파일 게이트 초안 생성
// node design-gate.mjs # gate.config.json 기준 실행
// node design-gate.mjs --update-baseline # 시각 기준화면 갱신(검증된 배포 후에만)
//
// 철학: 없는 도구는 SKIP(실패 아님), 검사 가능한 것은 전부 검사(실패면 exit 1).
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
const argv = process.argv.slice(2);
const cfgFile = path.resolve(process.cwd(), argv.includes("--init") ? "gate.config.json" : (argv.find((a) => a.startsWith("--config=")) || "gate.config.json").replace("--config=", ""));
const DEFAULT_CFG = {
pages: [
{
name: "app",
path: "index.html",
viewAttribute: "data-view",
views: ["main"],
h1: "h1",
brand: ".brand",
},
],
widths: [320, 375, 390, 768, 1024, 1440],
fontScales: [1, 1.3],
thresholds: { contrastNormal: 4.5, contrastLarge: 3, visualDiffPct: 0.1, stackLines: 3, radiusLiterals: 3, tapTargetMin: 44, inlineTargetMin: 24, leftInsetMin: 12 },
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, tapTargets: true, leftInset: true, static: false, visual: false, webkit: false, deadCss: true, cssHygiene: true },
deadCssIgnore: ["is-"], // 동적으로 조립되는 클래스 접두사(is-${type} 등). 오탐 방지 — 프로젝트마다 추가한다
chromePath: null,
};
if (argv.includes("--init")) {
fs.writeFileSync(cfgFile, JSON.stringify(DEFAULT_CFG, null, 2) + "\n");
console.log("gate.config.json 초안 생성 — pages/views 를 프로젝트에 맞게 고치세요.");
console.log("package.json: \"scripts\": { \"verify\": \"node <이 스크립트 경로>\" } 등록을 권장합니다.");
process.exit(0);
}
const _user = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
// checks·thresholds 는 깊은 병합 — 새 검사·임계값이 기본 켜지도록(실측: 프로젝트 구형 설정이 새 기본값을 지우고 "≥undefinedpx"를 찍었다)
const CFG = { ...DEFAULT_CFG, ..._user, checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) }, thresholds: { ...DEFAULT_CFG.thresholds, ...(_user.thresholds || {}) } };
const UPDATE = argv.includes("--update-baseline");
const results = [];
const pass = (name, detail = "") => results.push({ ok: true, name, detail });
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
const skip = (name, detail) => results.push({ ok: true, name, detail: "SKIP — " + detail });
// ── 브라우저 확보 (puppeteer-core + 크롬 경로 자동 탐색) ──
const CHROME_CANDIDATES = [
process.env.CHROME_PATH,
CFG.chromePath,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
].filter(Boolean);
const CHROME = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
const pageUrl = (p) => (p.url ? p.url : url.pathToFileURL(path.resolve(process.cwd(), p.path)).href);
// ── L0 정적 (옵션) ──
if (CFG.checks.static) {
const css = spawnSync("npx", ["stylelint", "**/*.css"], { shell: true, encoding: "utf8" });
css.status === 0 ? pass("L0 stylelint") : fail("L0 stylelint", (css.stdout || "").slice(0, 200));
const html = spawnSync("npx", ["html-validate", ...CFG.pages.map((p) => p.path)], { shell: true, encoding: "utf8" });
html.status === 0 ? pass("L0 html-validate") : fail("L0 html-validate", (html.stdout || "").slice(0, 200));
} else skip("L0 정적", "checks.static=false");
// ── L0 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
// 컴포넌트 제거 시 CSS 만 남아 12종의 미사용 선택자가 쌓였고, 토큰 밖 상수색 8건이 눈 검사를 통과했다) ──
const pageDir = (p) => path.dirname(path.resolve(process.cwd(), p.path));
const cssFilesOf = (p) => {
const out = [];
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/g)) {
const href = /href=["']([^"']+)["']/.exec(tag[0])?.[1];
if (href && !/^https?:/.test(href)) {
const f = path.resolve(pageDir(p), href.split("?")[0]);
if (fs.existsSync(f)) out.push(f);
}
}
// @import 로 딸려오는 로컬 파일(토큰 등)까지 따라간다
for (const f of [...out]) {
try {
for (const im of fs.readFileSync(f, "utf8").matchAll(/@import\s+(?:url\()?["']?([^"')]+)["']?\)?/g)) {
if (!/^https?:/.test(im[1])) {
const g = path.resolve(path.dirname(f), im[1]);
if (fs.existsSync(g) && !out.includes(g)) out.push(g);
}
}
} catch { /* 읽기 실패는 아래 검사가 각자 보고 */ }
}
return out;
};
if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) {
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); }
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; }
const files = cssFilesOf(p);
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
const dead = new Set();
for (const f of files) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!html.includes(cls)) dead.add(cls);
}
}
dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS 전 클래스 사용 중(무시 ${ignore.length}접두사)`);
}
} else skip("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) {
const files = cssFilesOf(p);
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f)));
const others = files.filter((f) => !tokenFiles.includes(f));
const hexes = []; const radii = new Set();
for (const f of others) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
}
const bad = [];
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`);
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`);
bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`);
}
} else skip("토큰 위생", "checks.cssHygiene=false");
// ── 브라우저 계층 ──
if (!CHROME) {
console.log("chrome/puppeteer-core 없음 — CHROME_PATH 를 설정하세요.");
process.exit(1);
}
let puppeteer;
try { puppeteer = await import("puppeteer-core"); }
catch { console.log("puppeteer-core 미설치 — npm i -D puppeteer-core 필요"); process.exit(1); }
const UTILS = `
window.__lines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const ls = [];
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
`;
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
const page = await browser.newPage();
await page.setCacheEnabled(false);
for (const p of CFG.pages) {
const href = pageUrl(p);
await page.setViewport({ width: 1440, height: 900 });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await page.evaluate(UTILS);
// ── SEO / meta (조사 기반 체크리스트) ──
if (CFG.checks.meta) {
const meta = await page.evaluate(() => {
const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; };
const title = document.title.trim();
const desc = get('meta[name="description"]');
const og = (k) => get(`meta[property="og:${k}"]`);
const imgs = [...document.querySelectorAll("img")];
return {
title, titleLen: title.length,
desc, descLen: desc ? desc.length : 0,
viewport: get('meta[name="viewport"]'),
lang: document.documentElement.getAttribute("lang"),
charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"),
canonical: get('link[rel="canonical"]', "href"),
ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"),
twitter: get('meta[name="twitter:card"]'),
robots: get('meta[name="robots"]'),
favicon: !!document.querySelector('link[rel~="icon"]'),
themeColor: get('meta[name="theme-color"]'),
bodyBg: (() => {
// 프리셋 전환에서 브랜드 표면이 누락되면 theme-color 만 옛 값으로 남는다 — 실제 배경과 비교해 잡는다
const b = getComputedStyle(document.body).backgroundColor;
return b && b !== "rgba(0, 0, 0, 0)" ? b : getComputedStyle(document.documentElement).backgroundColor;
})(),
h1Count: document.querySelectorAll("h1").length,
imgNoAlt: imgs.filter((i) => !i.getAttribute("alt")).length,
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].every((s) => { try { JSON.parse(s.textContent); return true; } catch { return false; } }),
jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length,
};
});
const m = [];
if (!meta.title) m.push("title 없음");
else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`);
if (!meta.desc) m.push("description 없음");
else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`);
if (!meta.viewport) m.push("viewport 없음");
if (!meta.lang) m.push("html lang 없음");
if (!meta.charset) m.push("charset 없음");
if (meta.ogTitle === null) m.push("og:title 없음");
if (meta.ogImage === null) m.push("og:image 없음");
if (meta.twitter === null) m.push("twitter:card 없음");
if (!meta.favicon) m.push("favicon 없음");
if (meta.themeColor && meta.bodyBg) {
const hexOf = (v) => {
const mm = /rgba?\((\d+),(\d+),(\d+)/.exec(v.replace(/\s+/g, ""));
return mm ? "#" + [1, 2, 3].map((i) => (+mm[i]).toString(16).padStart(2, "0")).join("") : v.replace(/\s+/g, "").toLowerCase();
};
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
}
if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
m.length ? fail(`${p.name} SEO/meta`, m.join(" · ")) : pass(`${p.name} SEO/meta`, `title ${meta.titleLen}자·desc ${meta.descLen}자·OG✓·alt ${meta.imgNoAlt}결`);
}
// ── 각 뷰 불변식 ──
const goView = async (v) => {
if (!p.viewAttribute || v === "single") return;
await page.evaluate((attr, name) => {
const btn = document.querySelector(`[${attr}="${name}"]`);
btn && btn.click();
}, p.viewAttribute, v);
await new Promise((r) => setTimeout(r, 120));
};
for (const v of p.views || ["single"]) {
await goView(v);
// 오버플로 — 전 폭
let overs = [];
for (const w of CFG.widths) {
await page.setViewport({ width: w, height: 900 });
await new Promise((r) => setTimeout(r, 60));
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) overs.push(`+${over}px@${w}`);
}
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// h1 1줄
await page.setViewport({ width: 1440, height: 900 });
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음"));
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) {
const stacked = await page.evaluate((limit) => {
const out = [];
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent || el.querySelector("*")) return;
const txt = el.textContent.trim();
if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10));
});
return [...new Set(out)].slice(0, 5);
}, CFG.thresholds.stackLines);
stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`);
}
// 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다
// (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙)
if (CFG.checks.rhythm) {
const rhythm = await page.evaluate(() => {
const groups = {};
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return;
const cls = el.classList[0];
const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls;
const r = el.getBoundingClientRect();
(groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement });
});
const issues = [];
for (const [key, items] of Object.entries(groups)) {
if (items.length < 4) continue;
const spread = (v) => Math.max(...v) - Math.min(...v);
const cls = key.split("|")[1];
const hUniform = spread(items.map((i) => i.h)) <= 4;
const txtUniform = spread(items.map((i) => i.t.length)) <= 1;
const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도
const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도
// 1) 트랙 폭: 숫자 라벨·빈 셀·한 축 정렬 라벨은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다.
// 한국어 라벨은 글자수가 같아도 폭이 달라지므로, 한 축 정렬 또는 숫자 전용일 때만 검사한다(오탐 방지).
const wSpread = spread(items.map((i) => i.w));
const alignedCol = spread(items.map((i) => i.x)) < 3;
if (hUniform && wSpread > 6 && (isDigits || isEmpty)) { // 숫자 라벨·빈 셀만 — 한국어 라벨은 내용 폭차가 자연스럽다
issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`);
}
// 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다
if (spread(items.map((i) => i.x)) < 3 && hUniform) {
const ys = items.map((i) => i.y).sort((a, b) => a - b);
const deltas = ys.slice(1).map((y, i) => y - ys[i]);
if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`);
} else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) {
const xs2 = items.map((i) => i.x).sort((a, b) => a - b);
const d2 = xs2.slice(1).map((x, i) => x - xs2[i]);
if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`);
}
}
return issues.slice(0, 4);
});
rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙");
}
// 대비
if (CFG.checks.contrast) {
const bad = await page.evaluate((cn, cl) => {
const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; };
const blend = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 });
const seen = new Set(); const out = [];
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent) return;
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
const cs = getComputedStyle(el);
const fg = rgb(cs.color); if (!fg) return;
let node = el; const stack = [];
while (node && node !== document.documentElement) {
const c = rgb(getComputedStyle(node).backgroundColor);
if (c && c.a >= 0.95) { stack.unshift(c); break; }
if (c && c.a > 0) stack.unshift(c);
node = node.parentElement;
}
let bg = { r: 255, g: 255, b: 255, a: 1 };
for (const s of stack) bg = blend(s, bg);
const fgb = blend(fg, bg);
const l1 = lum(fgb), l2 = lum(bg);
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
const size = parseFloat(cs.fontSize); const w = +cs.fontWeight || 400;
const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn;
if (ratio < need) {
const k = cs.color + Math.round(size);
if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); }
}
});
return out.slice(0, 4);
}, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge);
bad.length ? fail(`${p.name}/${v} 대비`, bad.join(",")) : pass(`${p.name}/${v} 대비 AA`);
}
}
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
if (CFG.checks.scaleMatrix) {
const bad = [];
for (const w of [375, 390]) {
for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
if (l > 1) bad.push(`${w}@${s}x:${l}줄`);
}
}
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`);
// 브랜드 1줄
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
if (p.brand) {
// 동일 셀렉터가 여러 개(사이드바+탑바 등)면 보이는 것을 잰다 — display:none 의 rect 는 0
const bl = await page.evaluate((sel) => {
const els = [...document.querySelectorAll(sel)];
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
return window.__lines(el);
}, p.brand);
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
}
}
// ── 터치 타깃 (모바일 — HIG 44pt·M3 48dp, WCAG 2.5.8 인라인 최소 24px.
// 실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는 보고로 시작된 검사.
// 기준: 컨트롤(button·input·role) 44px — 본문 흐름 속 텍스트 링크(a)·예외 선택자는 24px) ──
if (CFG.checks.tapTargets) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
await page.evaluate(UTILS);
for (const v of p.views || ["single"]) {
await goView(v);
const bad = await page.evaluate((min, inlineMin, inlineSel) => {
const out = [];
document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab'], [role='link'], [role='switch'], [role='checkbox']").forEach((el) => {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
const inline = cs.display === "inline" || (el.tagName === "A" && el.hasAttribute("href")) || (inlineSel || []).some((s) => el.matches(s));
const need = inline ? inlineMin : min;
if (Math.min(r.width, r.height) < need) {
const label = el.textContent.trim().slice(0, 8) || el.getAttribute("aria-label") || el.tagName.toLowerCase();
out.push(`"${label}" ${Math.round(r.width)}×${Math.round(r.height)}${inline ? "(인라인)" : ""}`);
}
});
return [...new Set(out)].slice(0, 5);
}, CFG.thresholds.tapTargetMin, CFG.thresholds.inlineTargetMin, CFG.tapTargetsInline || []);
bad.length ? fail(`${p.name}/${v} 터치타깃`, bad.join(" · ")) : pass(`${p.name}/${v} 터치타깃`, `컨트롤 ≥${CFG.thresholds.tapTargetMin}px·인라인 ≥${CFG.thresholds.inlineTargetMin}px`);
}
} else skip("터치타깃", "checks.tapTargets=false");
// ── 왼쪽 인셋(모바일) — 오버플로 검사는 오른쪽만 본다.
// 실측 사고: 제목 16px vs 앱바·카드 24px 혼재가 모든 게이트를 통과했고
// 사용자에게 '가장자리에 붙어 깨졌다'고 보고됐다. 텍스트 노드 Range 로 글자 시작점을 잰다
// (전폭 컨테이너의 보더박스가 아니라. display:none 의 rect=0 은 보임 검사로 제외). ──
if (CFG.checks.leftInset) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
for (const v of p.views || ["single"]) {
await goView(v);
const inset = await page.evaluate(() => {
let min = null;
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let n;
while ((n = walk.nextNode())) {
if (!n.textContent.trim() || !n.parentElement) continue;
const cs = getComputedStyle(n.parentElement);
if (cs.display === "none" || cs.visibility === "hidden" || cs.position === "fixed") continue;
const range = document.createRange();
range.selectNodeContents(n);
const r = range.getBoundingClientRect();
if (r.width < 2 || r.height < 4 || r.left < 0) continue;
const l = Math.round(r.left);
if (min === null || l < min) min = l;
}
return min;
});
inset === null
? skip(`${p.name}/${v} 왼쪽 인셋`, "텍스트 없음")
: inset >= CFG.thresholds.leftInsetMin
? pass(`${p.name}/${v} 왼쪽 인셋`, `최소 텍스트 시작 ${inset}px`)
: fail(`${p.name}/${v} 왼쪽 인셋`, `텍스트가 ${inset}px에서 시작 — 기준 ≥${CFG.thresholds.leftInsetMin}px(가장자리에 붙어 보인다)`);
}
} else skip("왼쪽 인셋", "checks.leftInset=false");
}
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
if (CFG.checks.visual) {
try {
const { default: pixelmatch } = await import("pixelmatch");
const { PNG } = await import("pngjs");
const BASE = path.resolve(process.cwd(), "__gate__/baseline");
fs.mkdirSync(BASE, { recursive: true });
let diffs = 0, n = 0;
for (const p of CFG.pages) {
for (const w of [390, 1440]) {
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
await page.goto(pageUrl(p), { waitUntil: "networkidle0" });
await new Promise((r) => setTimeout(r, 250));
const name = `${p.name}-${w}.png`;
const buf = await page.screenshot();
const cur = PNG.sync.read(buf);
const bf = path.join(BASE, name);
if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); continue; }
const base = PNG.sync.read(fs.readFileSync(bf));
if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; }
const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 });
const pct = (cnt / (base.width * base.height)) * 100;
n++;
if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); }
}
}
!diffs && pass("L3 시각 회귀", `${n}화면 ${UPDATE ? "기준갱신" : "일치"}`);
} catch (e) { skip("L3 시각 회귀", "pixelmatch/pngjs 미설치"); }
} else skip("L3 시각 회귀", "checks.visual=false");
await browser.close();
// ── L4 WebKit (옵션: playwright 설치 시) ──
if (CFG.checks.webkit) {
try {
const { webkit } = await import("playwright");
const b2 = await webkit.launch();
const pg = await b2.newPage();
await pg.setViewportSize({ width: 390, height: 844 });
let bad = 0;
for (const p of CFG.pages) {
await pg.goto(pageUrl(p), { waitUntil: "networkidle" });
const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; }
}
await b2.close();
!bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`);
} catch { skip("L4 WebKit", "playwright/webkit 미설치"); }
} else skip("L4 WebKit", "checks.webkit=false");
// ── 리포트 ──
const fails = results.filter((r) => !r.ok).length;
const lines = results.map((r) => `${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? " — " + r.detail : ""}`);
console.log(lines.join("\n"));
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
fs.writeFileSync(path.resolve(process.cwd(), "gate-report.md"),
`# 게이트 리포트 — ${stamp}\n\n${fails ? `**${fails}건 실패 — 배포 금지**` : "**전 항목 통과 — 배포 가능**"}\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`);
console.log(`\n${fails ? `게이트 실패(${fails})` : "게이트 통과"} — gate-report.md 생성`);
process.exit(fails ? 1 : 0);

View file

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

View file

@ -145,8 +145,10 @@ const PROBE = `() => {
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 textRange = document.createRange();
textRange.selectNodeContents(el);
const textHeight = textRange.getBoundingClientRect().height;
if (textHeight > lh * 1.6) wrapped.push(el.textContent.trim().slice(0, 26));
});
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
@ -181,7 +183,13 @@ const browser = await puppeteer.launch({
headless: "new",
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
args: [
"--no-sandbox",
"--allow-file-access-from-files",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--force-device-scale-factor=1",
],
});
let totalFail = 0;
@ -191,6 +199,7 @@ for (const slug of slugs) {
for (const file of pagesOf(slug)) {
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
for (const width of WIDTHS) {
console.log(`RUN ${rel} @${width}`);
const page = await browser.newPage();
const consoleErrors = [];
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));

View file

@ -0,0 +1,25 @@
# TWENTYOZ E2E — 2026-08-24 17:13
**148 assertions passed · GREEN**
- PASS responsive 320 — 13 passed
- PASS responsive 390 — 13 passed
- PASS responsive 768 — 13 passed
- PASS responsive 1440 — 13 passed
- PASS field interactions — 17 passed
- PASS content controls — 5 passed
- PASS official content — 2 passed
- PASS axe light — 3 passed
- PASS axe dark — 3 passed
- PASS fallbacks — 5 passed
- PASS WebGL — 13 passed
- PASS WebGL layout 390 — 6 passed
- PASS WebGL layout 768 — 6 passed
- PASS WebGL layout 1024 — 6 passed
- PASS WebGL layout 1440 — 6 passed
- PASS catalog 320 — 7 passed
- PASS catalog 1440 — 7 passed
- PASS catalog axe light — 1 passed
- PASS catalog axe dark — 1 passed
- PASS performance — 7 passed
- PASS runtime — 1 passed

View file

@ -0,0 +1,415 @@
// TWENTYOZ L6 — actual Chromium scenarios for the standalone showcase.
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";
import zlib from "node:zlib";
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 targetRoot = path.join(publicRoot, "work", "twentyoz-corp");
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"],
[".webp", "image/webp"],
[".png", "image/png"],
[".woff2", "font/woff2"],
[".glb", "model/gltf-binary"],
[".txt", "text/plain; charset=utf-8"],
]);
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 baseUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/index.html`;
const catalogUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/products.html`;
let pass = 0;
let fail = 0;
const failures = [];
const suites = new Map();
const assert = (condition, suite, name, detail = "") => {
const current = suites.get(suite) || { pass: 0, fail: 0 };
if (condition) {
pass += 1;
current.pass += 1;
} else {
fail += 1;
current.fail += 1;
failures.push(`${suite} · ${name}${detail ? ` — ${detail}` : ""}`);
}
suites.set(suite, current);
};
const assertEvery = (items, predicate, suite, name, describe = (item) => JSON.stringify(item)) => {
for (const item of items) assert(predicate(item), suite, name, describe(item));
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
});
const errors = [];
async function newPage() {
const page = await browser.newPage();
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
const location = message.location();
errors.push(`console: ${message.text()}${location.url ? ` @ ${location.url}` : ""}`);
}
});
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`response: ${response.status()} ${response.url()}`);
});
await page.evaluateOnNewDocument(() => {
window.__twentyozPerf = { cls: 0, lcp: 0 };
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__twentyozPerf.cls += entry.value;
}).observe({ type: "layout-shift", buffered: true });
new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length) window.__twentyozPerf.lcp = entries.at(-1).startTime;
}).observe({ type: "largest-contentful-paint", buffered: true });
});
return page;
}
async function goto(page, suffix = "") {
await page.goto(baseUrl + suffix, { waitUntil: "networkidle0", timeout: 30000 });
await page.evaluate(() => document.fonts.ready);
}
try {
// 1. Semantic structure, progressive fallback and responsive geometry.
const page = await newPage();
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);
const layout = await page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const h1 = document.querySelector("h1");
const lineHeight = Number.parseFloat(getComputedStyle(h1).lineHeight);
const navTops = [...document.querySelectorAll(".nav a")].map((link) => Math.round(link.getBoundingClientRect().top));
return {
width: innerWidth,
scrollWidth: document.documentElement.scrollWidth,
h1Count: document.querySelectorAll("h1").length,
h1Lines: Math.round(h1.getBoundingClientRect().height / lineHeight),
navTopCount: new Set(navTops).size,
checked: document.querySelector('input[name="field"]:checked')?.value,
visiblePanels: [...document.querySelectorAll(".field-panel")].filter(visible).map((panel) => panel.dataset.panel),
fieldTargets: [...document.querySelectorAll(".field-tabs label")].map((label) => {
const rect = label.getBoundingClientRect();
return { width: Math.round(rect.width), height: Math.round(rect.height) };
}),
brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).length,
webgl: document.querySelector(".hero").dataset.webgl,
fallbackOpacity: Number.parseFloat(getComputedStyle(document.querySelector(".core-poster")).opacity),
panelWidth: Math.round(document.querySelector(".field-panels").getBoundingClientRect().width),
tabsWidth: Math.round(document.querySelector(".field-tabs").getBoundingClientRect().width),
};
});
const suite = `responsive ${width}`;
assert(layout.scrollWidth <= layout.width, suite, "no horizontal document overflow", `${layout.scrollWidth}/${layout.width}`);
assert(layout.h1Count === 1, suite, "one h1", String(layout.h1Count));
assert(layout.h1Lines <= 2, suite, "hero title at most two lines", String(layout.h1Lines));
assert(layout.navTopCount === 1, suite, "navigation remains one row", String(layout.navTopCount));
assert(layout.checked === "edu", suite, "default field is education", String(layout.checked));
assert(layout.visiblePanels.length === 1 && layout.visiblePanels[0] === "edu", suite, "one matching panel visible", layout.visiblePanels.join(","));
assertEvery(layout.fieldTargets, (target) => target.height >= 44, suite, "field target height >=44", (target) => `${target.width}x${target.height}`);
assert(layout.brokenImages === 0, suite, "all images decode", String(layout.brokenImages));
assert(layout.webgl === "fallback" && layout.fallbackOpacity === 1, suite, "automation fallback is complete", `${layout.webgl}/${layout.fallbackOpacity}`);
assert(Math.abs(layout.panelWidth - layout.tabsWidth) <= 1, suite, "field panel fills the full tab width", `${layout.panelWidth}/${layout.tabsWidth}`);
}
// 2. All field controls: mouse and native keyboard radio behavior.
await page.setViewport({ width: 1440, height: 900 });
await goto(page);
const fieldCases = [
["field-edu", "edu", "VIVEN"],
["field-dc", "dc", "XD Hub"],
["field-gov", "gov", "EduBill"],
["field-plant", "plant", "ACS"],
];
for (const [id, value, proof] of fieldCases) {
await page.click(`label[for="${id}"]`);
const state = await page.evaluate((value) => {
const panel = document.querySelector(`[data-panel="${value}"]`);
return {
checked: document.querySelector('input[name="field"]:checked')?.value,
display: getComputedStyle(panel).display,
text: panel.textContent,
coreState: document.querySelector(".hero").dataset.coreState,
};
}, value);
assert(state.checked === value, "field interactions", `${value} radio checked`, state.checked);
assert(state.display !== "none", "field interactions", `${value} panel visible`, state.display);
assert(state.text.includes(proof), "field interactions", `${value} proof content`, state.text.slice(0, 80));
assert(state.coreState === value, "field interactions", `${value} core state synced`, state.coreState);
}
await page.focus("#field-edu");
await page.keyboard.press("ArrowRight");
assert(await page.$eval("#field-dc", (input) => input.checked), "field interactions", "arrow key changes native radio");
// 3. Archive disclosure and anchor navigation.
assert(!(await page.$eval("#career", (details) => details.open)), "content controls", "career starts closed");
await page.click("#career summary");
assert(await page.$eval("#career", (details) => details.open), "content controls", "career opens by summary");
assert((await page.$$eval(".jobs li", (items) => items.length)) === 5, "content controls", "five closed job records remain available");
assert((await page.$$eval(".record-list > li", (items) => items.length)) === 7, "official content", "seven current official records remain available");
assert((await page.$$eval('.record-list a[href="https://twentyoz.kr/news/111"]', (items) => items.length)) === 1, "official content", "latest official award record links to its source");
await page.click('.nav a[href="#ledger"]');
await wait(120);
assert((await page.evaluate(() => location.hash)) === "#ledger", "content controls", "work anchor updates URL");
assert((await page.evaluate(() => scrollY)) > 0, "content controls", "work anchor scrolls page");
// 4. Axe accessibility in light and dark fallback modes.
const axePath = require.resolve("axe-core/axe.min.js");
for (const theme of ["light", "dark"]) {
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
await goto(page);
await page.addScriptTag({ path: axePath });
const axe = await page.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
const axeDetail = axe.violations
.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`)
.join("; ");
assert(axe.violations.length === 0, `axe ${theme}`, "zero WCAG violations", axeDetail);
const themeProof = await page.evaluate(() => ({
background: getComputedStyle(document.body).backgroundColor,
poster: document.querySelector(".core-poster img").currentSrc,
}));
assert(theme === "light" ? themeProof.background === "rgb(233, 231, 226)" : themeProof.background === "rgb(13, 15, 15)", `axe ${theme}`, "theme background role applied", themeProof.background);
assert(theme === "light" ? themeProof.poster.includes("core-light.webp") : themeProof.poster.includes("core-dark.webp"), `axe ${theme}`, "matching poster selected", themeProof.poster);
}
await page.emulateMediaFeatures([]);
// 5. Reduced motion and explicit WebGL failure stay complete.
const reducedPage = await newPage();
await reducedPage.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
await reducedPage.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await goto(reducedPage, "?webgl=1");
const reduced = await reducedPage.evaluate(() => ({
webgl: document.querySelector(".hero").dataset.webgl,
reason: document.querySelector(".hero").dataset.webglReason,
canvasDisplay: getComputedStyle(document.querySelector("#coreCanvas")).display,
posterOpacity: getComputedStyle(document.querySelector(".core-poster")).opacity,
toggleHidden: document.querySelector("#motionToggle").hidden,
}));
assert(reduced.webgl === "fallback" && reduced.reason === "reduced-motion", "fallbacks", "reduced motion blocks WebGL", `${reduced.webgl}/${reduced.reason}`);
assert(reduced.canvasDisplay === "none" && reduced.posterOpacity === "1", "fallbacks", "reduced motion shows static poster", `${reduced.canvasDisplay}/${reduced.posterOpacity}`);
assert(reduced.toggleHidden, "fallbacks", "motion control hidden when no motion");
await reducedPage.close();
const failedGlPage = await newPage();
await failedGlPage.evaluateOnNewDocument(() => {
const original = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function getContext(type, ...args) {
if (type === "webgl2") return null;
return original.call(this, type, ...args);
};
});
await goto(failedGlPage, "?webgl=1");
const noGl = await failedGlPage.evaluate(() => ({
webgl: document.querySelector(".hero").dataset.webgl,
reason: document.querySelector(".hero").dataset.webglReason,
title: document.querySelector("h1").textContent.trim(),
poster: getComputedStyle(document.querySelector(".core-poster")).opacity,
}));
assert(noGl.webgl === "fallback" && noGl.reason === "no-webgl2", "fallbacks", "WebGL2 creation failure falls back", `${noGl.webgl}/${noGl.reason}`);
assert(noGl.title === "네 현장을 하나로" && noGl.poster === "1", "fallbacks", "DOM and poster survive WebGL failure", `${noGl.title}/${noGl.poster}`);
await failedGlPage.close();
// 6. Explicit real WebGL path, state sync, pause/replay, budgets and context loss.
const glPage = await newPage();
await glPage.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await goto(glPage, "?webgl=1");
await glPage.waitForSelector('.hero[data-webgl="ready"]', { timeout: 45000 });
await glPage.waitForSelector('.hero[data-model="ready"]', { timeout: 45000 });
const glReady = await glPage.evaluate(() => ({
canvasOpacity: getComputedStyle(document.querySelector("#coreCanvas")).opacity,
posterOpacity: getComputedStyle(document.querySelector(".core-poster")).opacity,
drawCalls: Number(document.querySelector("#coreCanvas").dataset.drawCalls),
dpr: Number(document.querySelector("#coreCanvas").dataset.dpr),
toggleHidden: document.querySelector("#motionToggle").hidden,
paused: document.querySelector("#motionToggle").getAttribute("aria-pressed"),
model: document.querySelector("#coreCanvas").dataset.model,
effects: document.querySelector("#coreCanvas").dataset.effects,
glowRings: document.querySelector("#coreCanvas").dataset.glowRings,
}));
assert(glReady.canvasOpacity === "1" && glReady.posterOpacity === "0", "WebGL", "canvas replaces poster after first frame", `${glReady.canvasOpacity}/${glReady.posterOpacity}`);
assert(Number.isFinite(glReady.drawCalls) && glReady.drawCalls <= 60, "WebGL", "mobile draw calls <=60", String(glReady.drawCalls));
assert(Number.isFinite(glReady.dpr) && glReady.dpr <= 1.35, "WebGL", "mobile DPR <=1.35", String(glReady.dpr));
assert(!glReady.toggleHidden && glReady.paused === "true", "WebGL", "automation begins paused with control", `${glReady.toggleHidden}/${glReady.paused}`);
assert(glReady.model === "ready", "WebGL", "licensed hard-surface GLB is rendered", glReady.model);
assert(glReady.effects.includes("field-shader") && glReady.effects.includes("bloom") && glReady.effects.includes("grain"), "WebGL", "shader and post-processing stack is active", glReady.effects);
assert(glReady.glowRings === "2", "WebGL", "two depth-separated glow rings are active", glReady.glowRings);
const coreLayoutCases = [
{ width: 390, height: 844, mobile: true, tier: "mobile", offset: 0, minRatio: 0.495, maxRatio: 0.505 },
{ width: 768, height: 900, mobile: false, tier: "tablet", offset: 0.9, minRatio: 0.72, maxRatio: 0.75 },
{ width: 1024, height: 768, mobile: false, tier: "compact", offset: 0.75, minRatio: 0.61, maxRatio: 0.64 },
{ width: 1440, height: 900, mobile: false, tier: "wide", offset: 0.72, minRatio: 0.58, maxRatio: 0.62 },
];
for (const expected of coreLayoutCases) {
await glPage.setViewport({ width: expected.width, height: expected.height, isMobile: expected.mobile, hasTouch: expected.mobile });
await glPage.waitForFunction(
(tier) => document.querySelector("#coreCanvas")?.dataset.layoutTier === tier,
{ timeout: 10000 },
expected.tier,
);
const placement = await glPage.$eval("#coreCanvas", (canvas) => ({
tier: canvas.dataset.layoutTier,
offset: Number(canvas.dataset.coreOffsetX),
ratio: Number(canvas.dataset.coreScreenRatio),
overflow: document.documentElement.scrollWidth - innerWidth,
actionRows: new Set(
[...document.querySelectorAll(".hero-actions > *")]
.filter((element) => getComputedStyle(element).display !== "none")
.map((element) => Math.round(element.getBoundingClientRect().top)),
).size,
motionHeight: document.querySelector("#motionToggle").getBoundingClientRect().height,
}));
const suite = `WebGL layout ${expected.width}`;
assert(placement.tier === expected.tier, suite, "responsive layout tier is selected", placement.tier);
assert(Math.abs(placement.offset - expected.offset) <= 0.01, suite, "core offset matches composition", String(placement.offset));
assert(placement.ratio >= expected.minRatio && placement.ratio <= expected.maxRatio, suite, "core screen position fills the hero center", String(placement.ratio));
assert(placement.overflow <= 0, suite, "visible 3D controls create no horizontal overflow", String(placement.overflow));
assert(placement.actionRows === 1, suite, "hero actions stay on one row", String(placement.actionRows));
assert(placement.motionHeight >= 44, suite, "motion control keeps a 44px target", String(placement.motionHeight));
}
await glPage.click('label[for="field-plant"]');
await glPage.waitForFunction(() => document.querySelector("#coreCanvas").dataset.coreState === "plant");
assert(await glPage.$eval("#coreCanvas", (canvas) => canvas.dataset.coreState === "plant"), "WebGL", "field selection reaches 3D state");
assert(await glPage.$eval("#coreStatus", (status) => status.textContent === "선택한 현장: 공장과 설비 현장" && status.classList.contains("sr-only")), "WebGL", "field state remains explicit for assistive technology");
await glPage.click("#motionToggle");
assert((await glPage.$eval("#motionToggle", (button) => button.getAttribute("aria-pressed"))) === "false", "WebGL", "motion replay control works");
assert((await glPage.$eval("#motionToggle", (button) => button.textContent.trim())) === "3D 모션 끄기", "WebGL", "motion control names the available action");
await wait(180);
await glPage.click("#motionToggle");
assert((await glPage.$eval("#motionToggle", (button) => button.getAttribute("aria-pressed"))) === "true", "WebGL", "motion pause control works");
await glPage.evaluate(() => {
const gl = document.querySelector("#coreCanvas").getContext("webgl2");
gl.getExtension("WEBGL_lose_context").loseContext();
});
await glPage.waitForFunction(() => document.querySelector(".hero").dataset.webgl === "fallback", { timeout: 5000 });
const lost = await glPage.evaluate(() => ({
reason: document.querySelector(".hero").dataset.webglReason,
poster: getComputedStyle(document.querySelector(".core-poster")).opacity,
hidden: document.querySelector("#motionToggle").hidden,
}));
assert(lost.reason === "context-lost" && lost.poster === "1" && lost.hidden, "WebGL", "context loss restores complete fallback", JSON.stringify(lost));
await glPage.close();
// 7. Product catalog: all four detailed systems, responsive layout and accessibility.
const catalogPage = await newPage();
for (const width of [320, 1440]) {
await catalogPage.setViewport({ width, height: width < 500 ? 844 : 900, isMobile: width < 500, hasTouch: width < 500 });
await catalogPage.goto(catalogUrl, { waitUntil: "networkidle0", timeout: 30000 });
await catalogPage.evaluate(() => document.fonts.ready);
const catalog = await catalogPage.evaluate(() => ({
width: innerWidth,
scrollWidth: document.documentElement.scrollWidth,
h1: document.querySelectorAll("h1").length,
specimens: [...document.querySelectorAll(".specimen")].map((item) => item.id),
capabilities: document.querySelectorAll(".capability-list li").length,
steps: document.querySelectorAll(".operation-flow li").length,
mailto: [...document.querySelectorAll('.specimen-action a[href^="mailto:"]')].length,
text: document.body.textContent,
}));
const suite = `catalog ${width}`;
assert(catalog.scrollWidth <= catalog.width, suite, "no horizontal overflow", `${catalog.scrollWidth}/${catalog.width}`);
assert(catalog.h1 === 1, suite, "one h1", String(catalog.h1));
assert(catalog.specimens.join(",") === "viven,edubill,xd-hub,xd-works", suite, "four product details are present", catalog.specimens.join(","));
assert(catalog.capabilities === 16, suite, "sixteen verified capabilities", String(catalog.capabilities));
assert(catalog.steps === 16, suite, "sixteen operating steps", String(catalog.steps));
assert(catalog.mailto === 4, suite, "four product inquiry actions", String(catalog.mailto));
assert(catalog.text.includes("1,000,000개") && catalog.text.includes("단일 공간 최대 100명") && catalog.text.includes("다통화 · 다과금 정책"), suite, "published product specifications remain available");
}
for (const theme of ["light", "dark"]) {
await catalogPage.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
await catalogPage.goto(catalogUrl, { waitUntil: "networkidle0", timeout: 30000 });
await catalogPage.addScriptTag({ path: axePath });
const axe = await catalogPage.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
const axeDetail = axe.violations.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`).join("; ");
assert(axe.violations.length === 0, `catalog axe ${theme}`, "zero WCAG violations", axeDetail);
}
await catalogPage.close();
// 8. Runtime and transfer budgets.
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
await page.setViewport({ width: 1440, height: 900 });
await goto(page);
await wait(900);
const perf = await page.evaluate(() => window.__twentyozPerf);
assert(perf.cls <= 0.1, "performance", "CLS <=0.1", String(perf.cls));
assert(perf.lcp > 0 && perf.lcp <= 2500, "performance", "LCP <=2.5s", String(perf.lcp));
const gzip = (relative) => zlib.gzipSync(fs.readFileSync(path.join(targetRoot, relative)), { level: 9 }).byteLength;
const initialAssets = ["index.html", "styles/main.css", "styles/tokens.css", "scripts/core-boot.js", "assets/core-light.webp", "assets/pretendard-twentyoz.woff2"];
const firstViewportBytes = initialAssets.reduce((sum, relative) => sum + fs.statSync(path.join(targetRoot, relative)).size, 0);
const coreGzip = gzip("assets/core.iife.js");
const modelBytes = fs.statSync(path.join(targetRoot, "assets/twentyoz-field-core.glb")).size;
const bootGzip = gzip("scripts/core-boot.js");
assert(firstViewportBytes <= 1024 * 1024, "performance", "first viewport assets <=1MB", `${firstViewportBytes}B`);
assert(coreGzip <= 200 * 1024, "performance", "WebGL bundle <=200KB gzip", `${coreGzip}B`);
assert(modelBytes <= 750 * 1024, "performance", "optimized GLB <=750KB", `${modelBytes}B`);
assert(coreGzip + modelBytes <= 1024 * 1024, "performance", "lazy 3D payload <=1MB", `${coreGzip + modelBytes}B`);
assert(bootGzip <= 35 * 1024, "performance", "hero bootstrap <=35KB gzip", `${bootGzip}B`);
assert(errors.length === 0, "runtime", "zero console and page errors", errors.join(" | "));
await page.close();
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
const lines = [...suites.entries()].map(([name, result]) => `${result.fail ? "FAIL" : "PASS"} ${name} — ${result.pass} passed${result.fail ? ` · ${result.fail} failed` : ""}`);
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
fs.writeFileSync(
path.join(here, "twentyoz-e2e-report.md"),
[`# TWENTYOZ E2E — ${stamp}`, "", fail ? `**${fail} failed · deploy blocked**` : `**${pass} assertions passed · GREEN**`, "", ...lines.map((line) => `- ${line}`), ...(failures.length ? ["", "## Failures", "", ...failures.map((item) => `- ${item}`)] : [])].join("\n") + "\n",
);
console.log(lines.join("\n"));
if (failures.length) {
console.log("\nFailures:");
failures.forEach((failure) => console.log(`FAIL ${failure}`));
}
console.log(`\nTWENTYOZ E2E — PASS ${pass} / FAIL ${fail}`);
process.exit(fail ? 1 : 0);

View file

@ -1,16 +1,20 @@
# 검증 리포트 — 2026-08-24 05:13
# 검증 리포트 — 2026-08-24 17:13
게이트: **전 계층 통과 — 배포 가능** · 총 103.0초
게이트: **전 계층 통과 — 배포 가능** · 총 165.5초
| 계층 | 결과 | 항목 | 소요 |
|---|---|---|---|
| L0-정적/stylelint | PASS | PASS — | 2.7s |
| L0-정식/html-validate | PASS | PASS — | 2.8s |
| L0-무결성/변수·경로·조판 | PASS | PASS 1 | 0.1s |
| L1-단위/계산로직 | PASS | PASS 41 | 0.0s |
| L2-기능/감사 | PASS | PASS 72 | 9.8s |
| L2-불변식/렌더 | PASS | PASS 10 | 16.7s |
| L3-시각회귀/diff | PASS | PASS 30 | 18.7s |
| L4-WebKit | PASS | PASS 8 | 3.8s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.0s |
| L6-시나리오/E2E | PASS | PASS — | 42.3s |
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.2s |
| L0-정적/stylelint | PASS | PASS — | 5.2s |
| L0-정식/html-validate | PASS | PASS — | 2.9s |
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.3s |
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
| L2-기능/감사 | PASS | PASS 72 | 10.9s |
| L2-불변식/렌더 | PASS | PASS 10 | 16.9s |
| L2-디자인 게이트/메인 | PASS | PASS 130 | 33.0s |
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.0s |
| L3-시각회귀/diff | PASS | PASS 30 | 12.0s |
| L4-WebKit | PASS | PASS 8 | 4.5s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.4s |
| L6-시나리오/E2E | PASS | PASS — | 43.2s |
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 23.0s |

View file

@ -11,13 +11,17 @@ const root = path.resolve(here, "..");
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
const steps = [
{
layer: "L0-자산/트웬티온스 3D",
cmd: () => spawnSync(process.execPath, ["tools/build-showcase-assets.mjs", "--check"], { cwd: root, encoding: "utf8" }),
},
{
layer: "L0-정적/stylelint",
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),
},
{
layer: "L0-정식/html-validate",
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html"], { cwd: root, encoding: "utf8", shell: true }),
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html", "public/work/twentyoz-corp/products.html"], { cwd: root, encoding: "utf8", shell: true }),
},
{
// 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도
@ -28,10 +32,13 @@ const steps = [
{ 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" }) },
{ layer: "L2-불변식/렌더", cmd: () => spawnSync(process.execPath, ["tools/invariants.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L2-디자인 게이트/메인", cmd: () => spawnSync(process.execPath, ["tools/design-gate.mjs", "--config=gate.config.json"], { cwd: root, encoding: "utf8" }) },
{ layer: "L2-디자인 게이트/제품", cmd: () => spawnSync(process.execPath, ["tools/design-gate.mjs", "--config=gate.twentyoz-products.config.json"], { cwd: root, encoding: "utf8" }) },
{ layer: "L3-시각회귀/diff", cmd: () => spawnSync(process.execPath, ["tools/visual.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: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
];
const report = [];
@ -43,8 +50,10 @@ for (const step of steps) {
const ok = r.status === 0;
if (!ok) failed++;
const out = (r.stdout || "") + (r.stderr || "");
const passLine = (out.match(/PASS\s+[^\n]/g) || []).length;
const failLines = (out.match(/FAIL\s+[^\n]/g) || []).length;
const summaries = [...out.matchAll(/PASS\s+(\d+)\s*\/\s*FAIL\s+(\d+)/g)];
const summary = summaries.at(-1);
const passLine = summary ? Number(summary[1]) : (out.match(/^PASS\b/gm) || []).length;
const failLines = summary ? Number(summary[2]) : (out.match(/^FAIL\b/gm) || []).length;
const tail = out.trim().split("\n").slice(-3).join(" | ").slice(0, 180);
report.push({ layer: step.layer, ok, ms: Date.now() - s, passLine, failLines, tail });
console.log(`${ok ? "✓" : "✗"} ${step.layer} (${((Date.now() - s) / 1000).toFixed(1)}s)${passLine ? ` — PASS ${passLine}` : ""}${failLines ? ` FAIL ${failLines}` : ""}`);

3
pnpm-lock.yaml generated
View file

@ -30,6 +30,9 @@ importers:
axe-core:
specifier: ^4.13.0
version: 4.13.0
esbuild:
specifier: ^0.25.12
version: 0.25.12
html-validate:
specifier: ^10.11.3
version: 10.17.0