This commit is contained in:
parent
cb3c323c9f
commit
9529e3747c
29 changed files with 8906 additions and 1326 deletions
|
|
@ -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 행에 둔다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
43
apps/site/gate.twentyoz-products.config.json
Normal file
43
apps/site/gate.twentyoz-products.config.json
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
BIN
apps/site/public/work/twentyoz-corp/assets/core-dark.webp
Normal file
BIN
apps/site/public/work/twentyoz-corp/assets/core-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/site/public/work/twentyoz-corp/assets/core-light.webp
Normal file
BIN
apps/site/public/work/twentyoz-corp/assets/core-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
4385
apps/site/public/work/twentyoz-corp/assets/core.iife.js
Normal file
4385
apps/site/public/work/twentyoz-corp/assets/core.iife.js
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -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.
|
||||
Binary file not shown.
|
|
@ -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&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&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>
|
||||
|
|
|
|||
161
apps/site/public/work/twentyoz-corp/products.html
Normal file
161
apps/site/public/work/twentyoz-corp/products.html
Normal 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>
|
||||
100
apps/site/public/work/twentyoz-corp/scripts/core-boot.js
Normal file
100
apps/site/public/work/twentyoz-corp/scripts/core-boot.js
Normal 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());
|
||||
})();
|
||||
352
apps/site/public/work/twentyoz-corp/styles/catalog.css
Normal file
352
apps/site/public/work/twentyoz-corp/styles/catalog.css
Normal 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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
229
apps/site/research/twentyoz-redesign-2026-08-24.md
Normal file
229
apps/site/research/twentyoz-redesign-2026-08-24.md
Normal 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 게이트가 하나의 배포 기준으로 연결되었다.
|
||||
660
apps/site/src/showcases/twentyoz-core.js
Normal file
660
apps/site/src/showcases/twentyoz-core.js
Normal 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 };
|
||||
}
|
||||
48
apps/site/tools/build-showcase-assets.mjs
Normal file
48
apps/site/tools/build-showcase-assets.mjs
Normal 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`);
|
||||
517
apps/site/tools/design-gate.mjs
Normal file
517
apps/site/tools/design-gate.mjs
Normal 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);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# E2E 시나리오 게이트 — 2026-08-24 05:13
|
||||
# E2E 시나리오 게이트 — 2026-08-24 17:13
|
||||
|
||||
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
25
apps/site/tools/twentyoz-e2e-report.md
Normal file
25
apps/site/tools/twentyoz-e2e-report.md
Normal 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
|
||||
415
apps/site/tools/twentyoz-e2e.mjs
Normal file
415
apps/site/tools/twentyoz-e2e.mjs
Normal 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);
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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}` : ""}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue