-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
4372 lines (3931 loc) · 212 KB
/
Copy pathindex.html
File metadata and controls
4372 lines (3931 loc) · 212 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>유이준 (Yoo Yijun) | 대전대신고등학교</title>
<link rel="icon" type="image/jpeg" href="static/favicon.ico" />
<!-- 검색용 -->
<!-- ㄴ 본문은 영어라 한글 학교명이 여기랑 JSON-LD에만 있음. 빼면 '대전대신고등학교 유이준' 검색 안 걸림 -->
<!-- ㄴ 도메인은 CNAME이랑 같아야 함 -->
<meta name="description" content="대전대신고등학교 재학생 유이준(Yoo Yijun, stx4R)의 포트폴리오. 컴퓨터공학·인공지능 지망 개발자로, 제8회 한국코드페어 본선에 진출했고 교내 대형 프로젝트를 기획·주도했으며 대학 주최 대회에서 다수 수상했습니다." />
<meta name="author" content="유이준 (Yoo Yijun)" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<link rel="canonical" href="https://stx4r.me/" />
<meta property="og:type" content="profile" />
<meta property="og:site_name" content="stx4R" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:url" content="https://stx4r.me/" />
<meta property="og:title" content="유이준 (Yoo Yijun) | 대전대신고등학교" />
<meta property="og:description" content="대전대신고등학교 재학생 유이준(Yoo Yijun, stx4R)의 포트폴리오. 제8회 한국코드페어 본선 진출, 교내 대형 프로젝트 주도, 대학 주최 대회 다수 수상." />
<meta property="og:image" content="https://stx4r.me/static/Profile.png" />
<meta property="profile:first_name" content="이준" />
<meta property="profile:last_name" content="유" />
<meta property="profile:username" content="stx4R" />
<meta name="twitter:card" content="summary_large_image" />
<!-- 구글/제미나이가 인물로 읽는 부분 -->
<!-- ㄴ 학교 바뀌면 affiliation, 링크 늘면 sameAs 같이 고칠 것 -->
<!-- ㄴ 본인 계정은 sameAs, 수상 명단·기사는 subjectOf -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"url": "https://stx4r.me/",
"name": "유이준 (Yoo Yijun) 포트폴리오",
"inLanguage": "ko-KR",
"mainEntity": {
"@type": "Person",
"@id": "https://stx4r.me/#person",
"name": "유이준",
"alternateName": ["Yoo Yijun", "Yijun Yoo", "stx4R"],
"url": "https://stx4r.me/",
"image": "https://stx4r.me/static/Profile.png",
"email": "mailto:contact@stx4r.me",
"jobTitle": "Creative Developer",
"description": "대전대신고등학교에 재학 중인 학생 개발자. 컴퓨터공학과 및 인공지능학과를 지망하며 풀스택 웹 개발, 디자인, AI 프로젝트를 만든다. 제8회 한국코드페어 본선에 진출했고, 교내 대형 프로젝트를 기획·주도했으며, 대학 주최 대회에서 다수 수상했다.",
"award": [
"제8회 한국코드페어(Korea Code Fair) 본선 진출",
"제15회 생활발명아이디어 경진대회 고등부 장려상 (부산교육대학교, 2025)",
"대학 주최 대회 다수 수상"
],
"subjectOf": [
{
"@type": "WebPage",
"name": "제15회 생활발명아이디어 경진대회 본선 결과 알림",
"url": "https://www.bnue.ac.kr/board/detail/community01_01/44514",
"datePublished": "2025-12-03",
"publisher": { "@type": "CollegeOrUniversity", "name": "부산교육대학교", "url": "https://www.bnue.ac.kr/" }
}
],
"affiliation": {
"@type": "HighSchool",
"name": "대전대신고등학교",
"alternateName": "Daejeon Daeshin High School",
"address": { "@type": "PostalAddress", "addressLocality": "대전광역시", "addressCountry": "KR" }
},
"alumniOf": [
{ "@type": "MiddleSchool", "name": "대전삼육중학교", "alternateName": "Daejeon Sahmyook Middle School" },
{ "@type": "ElementarySchool", "name": "대전삼육초등학교", "alternateName": "Daejeon Sahmyook Elementary School" }
],
"homeLocation": { "@type": "Place", "name": "대전광역시, 대한민국" },
"knowsLanguage": ["ko", "en", "zh", "ja"],
"knowsAbout": ["풀스택 웹 개발", "HTML", "CSS", "JavaScript", "Python", "UI 디자인", "인공지능"],
"sameAs": ["https://github.com/stx4R"]
}
}
</script>
<!-- 연결/사진은 파싱 중에 미리 깔아둠 -->
<!-- ㄴ 원래는 모듈 스크립트가 실행돼야 요청이 시작돼서 첫 화면이 그만큼 늦게 떴음 -->
<!-- ㄴ URL은 아래 ParticleImage의 SRC와 반드시 같아야 함. 다르면 두 번 받음 -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://images.unsplash.com" crossorigin />
<link rel="preload" as="image" crossorigin href="https://images.unsplash.com/photo-1574359587026-daf1bfd26baa?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
"rapier": "https://cdn.jsdelivr.net/npm/@dimforge/rapier3d-compat@0.14.0/+esm"
}
}
</script>
<!-- 무거운 모듈은 head에서 바로 받기 시작 -->
<!-- ㄴ 모듈 스크립트는 문서 순서대로 도는데 첫 번째(파티클)가 three를 static import함 -->
<!-- ㄴ 안 걸어두면 랜야드/푸터의 import()가 three 다 받은 뒤에야 출발해서 직렬로 늘어짐 -->
<!-- ㄴ URL은 위 importmap과 반드시 같아야 함. 다르면 두 번 받음 -->
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js" />
<script>
// 모바일 판정. 아래 CSS 모바일 블록이랑 같은 조건이어야 함
// ㄴ 767px 이하 + 가로모드 폰. 모바일은 자연 스크롤이라 페이지 넘김/3D 랜야드 안 씀
window.compactMQ = matchMedia('(max-width: 767px), (max-width: 1023px) and (max-height: 500px)');
// GLTFLoader, rapier는 랜야드/푸터 별 전용. 폰은 둘 다 안 띄우니 안 받음
// ㄴ link에 media 걸어도 modulepreload는 브라우저마다 무시해서 그냥 스크립트로 꽂음
if (!window.compactMQ.matches) {
for (const href of [
'https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/loaders/GLTFLoader.js',
'https://cdn.jsdelivr.net/npm/@dimforge/rapier3d-compat@0.14.0/+esm'
]) {
const l = document.createElement('link');
l.rel = 'modulepreload';
l.href = href;
document.head.appendChild(l);
}
}
// 새로고침하면 폰이 스크롤 위치를 되살려서 인트로를 건너뜀. 항상 맨 위부터
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
</script>
<!-- 프리로더가 쓰는 굵기만 미리. 숫자가 시스템 폰트로 떴다가 바뀌는 거 안 보이게 -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="static/fonts/subset/Pretendard-Bold.subset.woff2" />
<link rel="preload" as="font" type="font/woff2" crossorigin href="static/fonts/subset/Pretendard-SemiBold.subset.woff2" />
<style>
/* Pretendard 서브셋 -> static/fonts/subset */
/* ㄴ index.html에 쓰는것만 load, 본문 수정시 서브셋 재생성 */
/* ㄴ tools/ 는 v2.9.4에서 지움. 필요하면 git checkout 8812236 -- tools 로 꺼내서 python tools/subset-fonts.py */
@font-face { font-family: "Pretendard"; font-style: normal; font-weight: 300; font-display: swap; src: url("static/fonts/subset/Pretendard-Light.subset.woff2") format("woff2"); }
@font-face { font-family: "Pretendard"; font-style: normal; font-weight: 400; font-display: swap; src: url("static/fonts/subset/Pretendard-Regular.subset.woff2") format("woff2"); }
@font-face { font-family: "Pretendard"; font-style: normal; font-weight: 500; font-display: swap; src: url("static/fonts/subset/Pretendard-Medium.subset.woff2") format("woff2"); }
@font-face { font-family: "Pretendard"; font-style: normal; font-weight: 600; font-display: swap; src: url("static/fonts/subset/Pretendard-SemiBold.subset.woff2") format("woff2"); }
@font-face { font-family: "Pretendard"; font-style: normal; font-weight: 700; font-display: swap; src: url("static/fonts/subset/Pretendard-Bold.subset.woff2") format("woff2"); }
@font-face { font-family: "Pretendard"; font-style: normal; font-weight: 900; font-display: swap; src: url("static/fonts/subset/Pretendard-Black.subset.woff2") format("woff2"); }
/* 폴백용 원본 */
/* ㄴ if i was you i wanna be me too 서브셋에 없는 글자만!!!! 평소엔 요청 X */
/* ㄴ 로컬 사본(6.8MB) 지우고 jsDelivr로. pretendard@1.3.9 파일이랑 바이트까지 똑같음 */
@font-face { font-family: "PretendardFull"; font-style: normal; font-weight: 300; font-display: swap; src: url("https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/Pretendard-Light.woff2") format("woff2"); unicode-range: U+0020-007E, U+00A0-04FF, U+052F, U+0E3F, U+1D43-1D65, U+1D9C-1DA0, U+1DBB-20BF, U+20DB-213B, U+214D-21BB, U+21D0-21EA, U+2202-222B, U+2248, U+2260-226B, U+22EF, U+2303-2305, U+2318-232B, U+2380-238B, U+23CE-23CF, U+2423, U+2460-2473, U+24B6-24CF, U+24EA-24FF, U+25A0-25CF, U+25E6-2606, U+263C, U+2661-266A, U+26A0, U+2713-2717, U+2756, U+2776-2789, U+27EF-27FA, U+2985-2986, U+2B06-2B1C, U+2C7C-2C7F, U+2DFF, U+2E18, U+3000-301F, U+3041-30FE, U+3131-3164, U+318D, U+321C, U+3380-33DD, U+A69F, U+A7FF, U+A92E, U+AC00-D7A3, U+E000-E080, U+E093-E164, U+EE01-EE05, U+F000-F057, U+F6C3, U+FE20-FE2D, U+FEFF-FF60, U+FFA1-FFE6, U+1F10B, U+1F12F-1F189, U+1F850-1F852, U+F0000-F0002; }
@font-face { font-family: "PretendardFull"; font-style: normal; font-weight: 400; font-display: swap; src: url("https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/Pretendard-Regular.woff2") format("woff2"); unicode-range: U+0020-007E, U+00A0-04FF, U+052F, U+0E3F, U+1D43-1D65, U+1D9C-1DA0, U+1DBB-20BF, U+20DB-213B, U+214D-21BB, U+21D0-21EA, U+2202-222B, U+2248, U+2260-226B, U+22EF, U+2303-2305, U+2318-232B, U+2380-238B, U+23CE-23CF, U+2423, U+2460-2473, U+24B6-24CF, U+24EA-24FF, U+25A0-25CF, U+25E6-2606, U+263C, U+2661-266A, U+26A0, U+2713-2717, U+2756, U+2776-2789, U+27EF-27FA, U+2985-2986, U+2B06-2B1C, U+2C7C-2C7F, U+2DFF, U+2E18, U+3000-301F, U+3041-30FE, U+3131-3164, U+318D, U+321C, U+3380-33DD, U+A69F, U+A7FF, U+A92E, U+AC00-D7A3, U+E000-E080, U+E093-E164, U+EE01-EE05, U+F000-F057, U+F6C3, U+FE20-FE2D, U+FEFF-FF60, U+FFA1-FFE6, U+1F10B, U+1F12F-1F189, U+1F850-1F852, U+F0000-F0002; }
@font-face { font-family: "PretendardFull"; font-style: normal; font-weight: 500; font-display: swap; src: url("https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/Pretendard-Medium.woff2") format("woff2"); unicode-range: U+0020-007E, U+00A0-04FF, U+052F, U+0E3F, U+1D43-1D65, U+1D9C-1DA0, U+1DBB-20BF, U+20DB-213B, U+214D-21BB, U+21D0-21EA, U+2202-222B, U+2248, U+2260-226B, U+22EF, U+2303-2305, U+2318-232B, U+2380-238B, U+23CE-23CF, U+2423, U+2460-2473, U+24B6-24CF, U+24EA-24FF, U+25A0-25CF, U+25E6-2606, U+263C, U+2661-266A, U+26A0, U+2713-2717, U+2756, U+2776-2789, U+27EF-27FA, U+2985-2986, U+2B06-2B1C, U+2C7C-2C7F, U+2DFF, U+2E18, U+3000-301F, U+3041-30FE, U+3131-3164, U+318D, U+321C, U+3380-33DD, U+A69F, U+A7FF, U+A92E, U+AC00-D7A3, U+E000-E080, U+E093-E164, U+EE01-EE05, U+F000-F057, U+F6C3, U+FE20-FE2D, U+FEFF-FF60, U+FFA1-FFE6, U+1F10B, U+1F12F-1F189, U+1F850-1F852, U+F0000-F0002; }
@font-face { font-family: "PretendardFull"; font-style: normal; font-weight: 600; font-display: swap; src: url("https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/Pretendard-SemiBold.woff2") format("woff2"); unicode-range: U+0020-007E, U+00A0-04FF, U+052F, U+0E3F, U+1D43-1D65, U+1D9C-1DA0, U+1DBB-20BF, U+20DB-213B, U+214D-21BB, U+21D0-21EA, U+2202-222B, U+2248, U+2260-226B, U+22EF, U+2303-2305, U+2318-232B, U+2380-238B, U+23CE-23CF, U+2423, U+2460-2473, U+24B6-24CF, U+24EA-24FF, U+25A0-25CF, U+25E6-2606, U+263C, U+2661-266A, U+26A0, U+2713-2717, U+2756, U+2776-2789, U+27EF-27FA, U+2985-2986, U+2B06-2B1C, U+2C7C-2C7F, U+2DFF, U+2E18, U+3000-301F, U+3041-30FE, U+3131-3164, U+318D, U+321C, U+3380-33DD, U+A69F, U+A7FF, U+A92E, U+AC00-D7A3, U+E000-E080, U+E093-E164, U+EE01-EE05, U+F000-F057, U+F6C3, U+FE20-FE2D, U+FEFF-FF60, U+FFA1-FFE6, U+1F10B, U+1F12F-1F189, U+1F850-1F852, U+F0000-F0002; }
@font-face { font-family: "PretendardFull"; font-style: normal; font-weight: 700; font-display: swap; src: url("https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/Pretendard-Bold.woff2") format("woff2"); unicode-range: U+0020-007E, U+00A0-04FF, U+052F, U+0E3F, U+1D43-1D65, U+1D9C-1DA0, U+1DBB-20BF, U+20DB-213B, U+214D-21BB, U+21D0-21EA, U+2202-222B, U+2248, U+2260-226B, U+22EF, U+2303-2305, U+2318-232B, U+2380-238B, U+23CE-23CF, U+2423, U+2460-2473, U+24B6-24CF, U+24EA-24FF, U+25A0-25CF, U+25E6-2606, U+263C, U+2661-266A, U+26A0, U+2713-2717, U+2756, U+2776-2789, U+27EF-27FA, U+2985-2986, U+2B06-2B1C, U+2C7C-2C7F, U+2DFF, U+2E18, U+3000-301F, U+3041-30FE, U+3131-3164, U+318D, U+321C, U+3380-33DD, U+A69F, U+A7FF, U+A92E, U+AC00-D7A3, U+E000-E080, U+E093-E164, U+EE01-EE05, U+F000-F057, U+F6C3, U+FE20-FE2D, U+FEFF-FF60, U+FFA1-FFE6, U+1F10B, U+1F12F-1F189, U+1F850-1F852, U+F0000-F0002; }
@font-face { font-family: "PretendardFull"; font-style: normal; font-weight: 900; font-display: swap; src: url("https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/Pretendard-Black.woff2") format("woff2"); unicode-range: U+0020-007E, U+00A0-04FF, U+052F, U+0E3F, U+1D43-1D65, U+1D9C-1DA0, U+1DBB-20BF, U+20DB-213B, U+214D-21BB, U+21D0-21EA, U+2202-222B, U+2248, U+2260-226B, U+22EF, U+2303-2305, U+2318-232B, U+2380-238B, U+23CE-23CF, U+2423, U+2460-2473, U+24B6-24CF, U+24EA-24FF, U+25A0-25CF, U+25E6-2606, U+263C, U+2661-266A, U+26A0, U+2713-2717, U+2756, U+2776-2789, U+27EF-27FA, U+2985-2986, U+2B06-2B1C, U+2C7C-2C7F, U+2DFF, U+2E18, U+3000-301F, U+3041-30FE, U+3131-3164, U+318D, U+321C, U+3380-33DD, U+A69F, U+A7FF, U+A92E, U+AC00-D7A3, U+E000-E080, U+E093-E164, U+EE01-EE05, U+F000-F057, U+F6C3, U+FE20-FE2D, U+FEFF-FF60, U+FFA1-FFE6, U+1F10B, U+1F12F-1F189, U+1F850-1F852, U+F0000-F0002; }
body, html {
font-family: "Pretendard", "PretendardFull", -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
}
html, body { height: 100%; overflow: hidden; }
::-webkit-scrollbar { display: none; }
html { scrollbar-width: none; }
/* Preloader (pro.reactbits.dev/docs/components/preloader) */
/* ㄴ percentage 변형 그대로. 배경 #000, 가운데 숫자, 바닥에 진행 바 */
/* ㄴ 숫자 위 라벨은 stairs 변형의 loadingText 방식(단어별 블러 등장). 99%부터 흐려짐 */
/* ㄴ Tailwind 안 씀. 이게 맨 처음 뜨는 화면이라 CSS 파일 기다리면 안 됨 */
#preloader {
position: fixed; inset: 0; z-index: 10000;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: #000; color: #fff;
transition: opacity 500ms cubic-bezier(0.4, 0, 0.2, 1);
touch-action: none; /* 폰은 문서가 스크롤돼서, 로딩 중에 밀면 뒤에서 인트로가 먼저 지나가버림 */
}
#preloader.is-out { opacity: 0; pointer-events: none; }
#preloaderLabel {
display: flex; flex-wrap: wrap; justify-content: center; gap: 0 0.3em;
margin: 0; padding: 0 6vw;
font-weight: 600; letter-spacing: -0.01em; line-height: 1.3;
font-size: clamp(1rem, 3vw, 1.625rem); color: #a3a3a3;
transition: opacity 320ms ease, filter 320ms ease;
}
/* 단어 단위만. 안쪽 글자 span까지 걸리면 PERFECT 글자마다 블러가 따로 돎 */
#preloaderLabel > span { animation: preloaderWord 600ms ease both; }
#preloaderLabel > span:nth-child(2) { animation-delay: 110ms; }
#preloaderLabel > span:nth-child(3) { animation-delay: 220ms; }
@keyframes preloaderWord { from { opacity: 0; filter: blur(10px); } to { opacity: 1; filter: blur(0); } }
#preloader.is-textfade #preloaderLabel { opacity: 0; filter: blur(10px); }
/* Decrypted Text (reactbits.dev/text-animations/decrypted-text) */
/* ㄴ 아직 안 풀린 글자는 한 단계 어둡게 */
#preloaderDecrypt .enc { color: #737373; }
#preloaderCount {
font-weight: 700; line-height: 1; letter-spacing: -0.05em;
font-size: min(25vw, 200px); font-variant-numeric: tabular-nums;
}
#preloaderCount .sign { font-size: min(12vw, 100px); opacity: 0.5; }
#preloaderBar { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: #1f1f1f; }
#preloaderFill { height: 100%; width: 0; background: #fff; }
#projectWheel .wheel-item { position: absolute; top: 50%; left: 12px; white-space: nowrap; line-height: 1; font-weight: 300; transform-origin: left center; cursor: pointer; font-size: clamp(1.5rem, 2.4vw, 2.25rem); color: color-mix(in srgb, #ffffff calc(var(--wp, 0) * 100%), #a6a6a6); }
#projectWheel .wheel-item--selected { font-weight: 600; }
#fuckBentoBox.is-on #projectWheel .wheel-item { will-change: transform, opacity, filter; }
/* CardSwap */
#projectCardStage .cs-stack { position: absolute; inset: 0 0 40px 0; }
#projectCardStage .cs-card {
position: absolute; inset: 0;
border-radius: 12px; border: 1px solid #fff; background: #0d1117; overflow: hidden;
}
#projectCardStage .cs-card > * { animation: csEnter 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both; }
@keyframes csEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (max-width: 1023px) {
#projectCardStage .cs-stack { inset: 0 0 16px 0; }
}
/* R3P0RT 보고서 (github.com/stx4R/R3P0RT) */
/* ㄴ github.com 파일 화면 흉내. 색/간격은 GitHub 다크 기본 테마 값 그대로 */
/* ㄴ 본문 스타일은 github-markdown-css가 함. 여기는 껍데기랑 Tailwind preflight가 지운 것만 */
/* ㄴ 박스 안만 브라우저 50%로 본 크기. 100%면 카드에 글이 몇 줄 안 들어감 */
/* ㄴ zoom이라 흐려지지 않음. 폭 판정(@container)은 .gh-in 기준이라 줄어든 뒤 폭으로 잼 */
#projectCardStage .gh {
position: relative; height: 100%; overflow-y: auto; overscroll-behavior: contain;
zoom: 0.5;
color: #f0f6fc; font-size: 14px; line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
#projectCardStage .gh-in { container-type: inline-size; padding: 16px 24px 24px; }
#projectCardStage .gh-ico { display: inline-block; width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; }
#projectCardStage .gh-copy .gh-ok { display: none; color: #3fb950; }
#projectCardStage .gh-copy.is-copied .gh-ico { display: none; }
#projectCardStage .gh-copy.is-copied .gh-ok { display: inline-block; }
#projectCardStage .gh-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 32px; padding: 0 12px; border: 1px solid #3d444d; border-radius: 6px;
background: #212830; color: #f0f6fc; font-size: 14px; font-weight: 500; white-space: nowrap;
transition: background-color 80ms;
}
#projectCardStage .gh-btn:hover { background: #262c36; }
#projectCardStage .gh-btn--sm { height: 28px; padding: 0 8px; font-size: 12px; }
#projectCardStage .gh-btn--icon { width: 32px; padding: 0; color: #9198a1; }
#projectCardStage .gh-btn--sm.gh-btn--icon { width: 28px; }
#projectCardStage .gh-btn--caret { width: 24px; }
#projectCardStage .gh-iconbtn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 6px; color: #9198a1; transition: background-color 80ms;
}
#projectCardStage .gh-iconbtn:hover { background: #656c7633; color: #f0f6fc; }
#projectCardStage .gh-group { display: inline-flex; }
#projectCardStage .gh-group > * { border-radius: 0; }
#projectCardStage .gh-group > * + * { margin-left: -1px; }
#projectCardStage .gh-group > :first-child { border-radius: 6px 0 0 6px; }
#projectCardStage .gh-group > :last-child { border-radius: 0 6px 6px 0; }
/* 경로 */
#projectCardStage .gh-crumb { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; margin-bottom: 16px; }
#projectCardStage .gh-path { display: flex; align-items: center; flex-wrap: wrap; min-width: 0; font-size: 16px; font-weight: 600; }
#projectCardStage .gh-path a { color: #4493f8; }
#projectCardStage .gh-path a:hover { text-decoration: underline; }
#projectCardStage .gh-sep { margin: 0 6px; color: #9198a1; font-weight: 400; }
#projectCardStage .gh-file { color: #f0f6fc; word-break: break-all; }
#projectCardStage .gh-path .gh-iconbtn { margin-left: 4px; }
/* 최근 커밋 */
#projectCardStage .gh-commit {
display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 8px 8px 8px 16px; margin-bottom: 16px;
border: 1px solid #3d444d; border-radius: 6px; color: #9198a1;
}
#projectCardStage .gh-avatar img { display: block; width: 20px; height: 20px; border-radius: 50%; box-shadow: 0 0 0 1px #ffffff26; }
#projectCardStage .gh-author { color: #f0f6fc; font-weight: 600; }
#projectCardStage .gh-author:hover { text-decoration: underline; }
#projectCardStage .gh-msg { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
#projectCardStage .gh-msg:hover { color: #4493f8; text-decoration: underline; }
#projectCardStage .gh-when { font-size: 12px; white-space: nowrap; }
#projectCardStage .gh-when a:hover { color: #4493f8; text-decoration: underline; }
#projectCardStage .gh-hist { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 8px; border-radius: 6px; color: #f0f6fc; font-weight: 600; white-space: nowrap; }
#projectCardStage .gh-hist:hover { background: #656c7633; }
#projectCardStage .gh-hist .gh-ico { color: #9198a1; }
/* 파일 박스. 머리는 스크롤해도 위에 붙어 있음 */
#projectCardStage .gh-box { border: 1px solid #3d444d; border-radius: 6px; }
#projectCardStage .gh-head {
position: sticky; top: 0; z-index: 3;
display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 8px;
background: #151b23; border-bottom: 1px solid #3d444d; border-radius: 6px 6px 0 0;
}
#projectCardStage .gh-seg { display: inline-flex; height: 32px; padding: 0; background: #010409; border: 1px solid #3d444d; border-radius: 6px; flex-shrink: 0; }
#projectCardStage .gh-seg > * { position: relative; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 6px; color: #f0f6fc; font-size: 14px; }
#projectCardStage .gh-seg > * + *::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 1px; background: #3d444d; }
#projectCardStage .gh-seg > .is-on::before, #projectCardStage .gh-seg > .is-on + *::before { display: none; }
#projectCardStage .gh-seg > :not(.is-on):hover { background: #656c7633; }
#projectCardStage .gh-seg > .is-on { margin: -1px; background: #262c36; border: 1px solid #3d444d; font-weight: 600; }
#projectCardStage .gh-meta { margin-left: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9198a1; font-size: 12px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
#projectCardStage .gh-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
#projectCardStage .gh[data-mode="preview"] .gh-pane--code,
#projectCardStage .gh[data-mode="code"] .gh-pane--preview { display: none; }
#projectCardStage .gh-wait { display: flex; align-items: center; justify-content: center; min-height: 240px; color: #9198a1; }
#projectCardStage .gh-spinner, #projectCardStage .anim-rotate { animation: ghSpin 1s linear infinite; }
@keyframes ghSpin { to { transform: rotate(360deg); } }
#projectCardStage .gh-fail { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 64px 16px; color: #9198a1; text-align: center; }
#projectCardStage .gh-fail div { display: flex; gap: 8px; }
/* Code 탭 */
#projectCardStage .gh-code { display: flex; padding: 8px 0; overflow-x: auto; font-size: 12px; line-height: 20px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
#projectCardStage .gh-ln { position: sticky; left: 0; flex: none; padding: 0 8px 0 16px; background: #0d1117; color: #9198a1; text-align: right; white-space: pre; user-select: none; }
#projectCardStage .gh-src { flex: 1 0 auto; margin: 0; padding: 0 16px 0 8px; font: inherit; color: #f0f6fc; white-space: pre; tab-size: 8; }
/* Preview 탭 */
#projectCardStage .gh-pane--preview .markdown-body { padding: 32px; background: transparent; }
#projectCardStage .markdown-body img { display: inline; vertical-align: baseline; }
#projectCardStage .markdown-body ul { list-style-type: disc; }
#projectCardStage .markdown-body ol { list-style-type: decimal; }
#projectCardStage .markdown-body ul ul { list-style-type: circle; }
#projectCardStage .markdown-body ul ul ul { list-style-type: square; }
#projectCardStage .markdown-body markdown-accessiblity-table { display: block; }
#projectCardStage .markdown-body .markdown-heading { position: relative; }
#projectCardStage .markdown-body .markdown-heading .anchor {
position: absolute; top: 50%; left: -28px; float: none; margin: 0; padding: 0;
display: flex; align-items: center; justify-content: center; width: 28px; height: 28px;
border-radius: 6px; color: #f0f6fc; opacity: 0; transform: translateY(-50%);
}
#projectCardStage .markdown-body .markdown-heading:hover .anchor { opacity: 1; }
#projectCardStage .markdown-body .render-plaintext-hidden { display: none; }
#projectCardStage .markdown-body .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
#projectCardStage .gh-mermaid { display: flex; justify-content: center; margin-bottom: 16px; overflow-x: auto; }
#projectCardStage .gh-mermaid svg { display: block; max-width: 100%; height: auto; }
#projectCardStage .gh-mermaid .gh-wait { min-height: 100px; }
/* 박스가 좁을 때. 모바일이거나 창 줄였을 때 */
/* ㄴ GitHub도 좁아지면 줄 수랑 편집 버튼부터 숨김 */
@container (max-width: 560px) {
#projectCardStage .gh-in { padding: 12px 12px 16px; }
#projectCardStage .gh-crumb { margin-bottom: 12px; }
#projectCardStage .gh-path { font-size: 14px; }
#projectCardStage .gh-commit { min-height: 40px; padding: 4px 4px 4px 12px; margin-bottom: 12px; }
#projectCardStage .gh-hist span { display: none; }
#projectCardStage .gh-meta, #projectCardStage .gh-edit, #projectCardStage .gh-outline { display: none; }
#projectCardStage .gh-seg > * { padding: 0 10px; }
#projectCardStage .gh-pane--preview .markdown-body { padding: 16px; font-size: 14px; }
#projectCardStage .markdown-body .markdown-heading .anchor { display: none; }
}
@container (max-width: 400px) {
/* 경로 복사 버튼은 폰 폭에서 혼자 다음 줄로 떨어짐 */
#projectCardStage .gh-when, #projectCardStage .gh-group > :not(:first-child), #projectCardStage .gh-path .gh-copy { display: none; }
#projectCardStage .gh-group > :first-child { border-radius: 6px; }
}
#stackStage .stack-item { position: absolute; left: 50%; top: 50%; display: flex; flex-direction: column; align-items: center; gap: 14px; transition: transform 600ms cubic-bezier(0.22, 0.61, 0.36, 1); }
#fuckinteraction.is-on #stackStage .stack-item { will-change: transform; }
#stackStage .stack-item .stack-card { background: linear-gradient(#2b2b2b, #191919); border-radius: 16px; display: flex; align-items: center; justify-content: center; transition: filter 200ms; }
#stackStage .stack-item:hover .stack-card { filter: brightness(1.3); }
.lanyard-wrapper { position: relative; z-index: 0; width: 100%; height: 100vh; display: flex; justify-content: center; align-items: center; }
.lanyard-wrapper canvas { width: 100%; height: 100%; display: block; touch-action: none; }
/* OS별 이모지가 다르기에, Fluent Emoji로 고정 */
img.emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.2em; }
/* Blur Highlight */
.js-reveal [data-reveal] {
opacity: 0.3; filter: blur(8px); transform: translateY(6px);
transition: opacity 520ms ease var(--d, 0ms),
filter 520ms ease var(--d, 0ms),
transform 520ms cubic-bezier(0.22, 0.61, 0.36, 1) var(--d, 0ms);
}
.js-reveal [data-reveal].is-in { opacity: 1; filter: blur(0); transform: none; }
[data-hl] {
background-color: transparent;
background-image: linear-gradient(var(--hl), var(--hl));
background-repeat: no-repeat;
background-position: var(--hl-pos, left center);
background-size: var(--hl-from, 0% 100%);
color: var(--ink0, #d4d4d4);
}
.js-reveal [data-hl] {
transition: background-size 620ms cubic-bezier(0.22, 0.61, 0.36, 1) var(--hl-delay, 520ms),
color 260ms linear calc(var(--hl-delay, 520ms) + 140ms),
opacity 520ms ease var(--d, 0ms),
filter 520ms ease var(--d, 0ms),
transform 520ms cubic-bezier(0.22, 0.61, 0.36, 1) var(--d, 0ms);
}
[data-hl].is-in { background-size: 100% 100%; color: var(--ink, #000); }
[data-hl-dir="bottom"] { --hl-from: 100% 0%; --hl-pos: left bottom; }
[data-hl-dir="right"] { --hl-pos: right center; }
/* Intro : Scroll Mask + Particle Image */
#introStage { --iris-in: 0px; --iris-out: 0px; }
#introReveal { transform: scale(var(--zoom, 1.12)); transform-origin: 50% 50%; background: #000; opacity: var(--live, 1); will-change: transform, opacity; }
#introCover {
background: #171717;
-webkit-mask-image: radial-gradient(circle at 50% 50%, transparent var(--iris-in), #000 var(--iris-out));
mask-image: radial-gradient(circle at 50% 50%, transparent var(--iris-in), #000 var(--iris-out));
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
#introHelloLayer { opacity: var(--hello, 1); transform: translateY(calc((1 - var(--hello, 1)) * -28px)); will-change: opacity, transform; }
#introQuote { position: absolute; left: 0; right: 0; top: 43.5%; transform: translateY(-50%); }
#introQuote .q1 { font-weight: 900; font-style: italic; letter-spacing: -0.035em; line-height: 1.06; font-size: clamp(1.8rem, 5.6vw, 6.2rem); color: #fafafa; }
#introQuote .q2row { position: relative; margin-top: 0.7rem; }
#introQuote .q2 { font-weight: 900; letter-spacing: -0.03em; line-height: 1.35; font-size: clamp(1.05rem, 2.8vw, 3.1rem); color: #d4d4d4; }
#introQuoteBy { position: absolute; top: calc(100% + 1.6rem); right: 9vw; text-align: right; font-weight: 600; letter-spacing: -0.01em; line-height: 1.45; font-size: clamp(0.85rem, 1.5vw, 1.55rem); color: #a3a3a3; }
#introScrollHint { position: absolute; left: 50%; top: 71%; transform: translate(-50%, -50%); }
@media (max-width: 900px) {
#introQuoteBy { position: static; margin-top: 1.1rem; }
}
/* Depth Text (reactbits.dev/text-animations/depth-text) */
/* ㄴ 인용구 두 줄. 같은 글자를 뒤로 겹겹이 깔고 스테이지째 기울임. 장 쌓는 건 스크립트 */
/* ㄴ 글꼴/크기/자간/줄높이는 .q1 .q2 것 상속. 원본 CSS의 line-height 0.86, nowrap은 안 씀. 모바일에서 줄바꿈 돼야 함 */
/* ㄴ 원근/그림자는 원본 px를 em으로. 두 줄 크기가 두 배 차이라 px면 작은 줄이 뭉개짐 */
/* ㄴ 원근 900px은 단어 하나용. 문장 한 줄(1000px+)에 걸면 양끝이 가운데로 쏠려서 잔상처럼 번짐. 멀리 두면 뒷장이 평행하게 밀려 두께로 읽힘 */
.depth-text { perspective: 30em; perspective-origin: 50% 48%; isolation: isolate; }
.depth-text__stage {
position: relative; display: inline-grid; place-items: center;
transform-style: preserve-3d; transform: rotateX(-2.4deg) rotateY(3.15deg); transform-origin: 50% 50%;
}
#fuckintro.is-on .depth-text__stage { will-change: transform; }
.depth-text__layer, .depth-text__face {
grid-area: 1 / 1; display: inline-block;
transform-style: preserve-3d; backface-visibility: hidden; text-rendering: geometricPrecision;
}
.depth-text__layer { position: absolute; inset: 0; z-index: 0; filter: saturate(0.95) brightness(0.92); pointer-events: none; }
.depth-text__face {
position: relative; z-index: 1; transform: translateZ(0.6px);
text-shadow: 0 0.2em 0.3em rgba(0, 0, 0, 0.36), 0 0.04em 0.07em rgba(0, 0, 0, 0.28);
}
/* 1번 박스, Heavy. */
#introHead, #introSign {
opacity: calc(var(--in, 0) * var(--live, 1));
filter: blur(calc((1 - var(--in, 0)) * 7px));
will-change: opacity, transform, filter;
}
#introHead { transform: translateY(calc((1 - var(--in, 0)) * 20px - var(--exit, 0) * 46vh)); }
#introSign { transform: translateY(calc((1 - var(--in, 0)) * 20px + var(--exit, 0) * 46vh)); }
#introHead .t1 { font-weight: 900; letter-spacing: -0.035em; line-height: 1.14; font-size: clamp(1.9rem, 4.3vw, 4rem); }
#introHead .t2 { font-weight: 700; letter-spacing: -0.02em; line-height: 1.5; font-size: clamp(0.95rem, 1.6vw, 1.5rem); color: #d4d4d4; margin-top: 0.85rem; }
/* 2번 박스, Extra Bold. */
#introSign .s1 { font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; font-size: clamp(0.7rem, 1vw, 0.95rem); color: #a3a3a3; }
#introSign .s2 { font-weight: 600; letter-spacing: -0.01em; font-size: clamp(1rem, 1.5vw, 1.45rem); color: #fafafa; margin-top: 0.3rem; }
#particleImageStage canvas { display: block; width: 100%; height: 100%; }
#introPhoto { object-fit: cover; opacity: var(--photo, 0); }
#introScrim { display: none; }
@media (max-width: 767px) {
#introQuote { padding: 0 6vw; }
#introScrollHint { top: 60%; }
#introHead { right: 6vw; left: 6vw; top: 7vh; max-width: none; }
#introSign { left: 6vw; bottom: 7vh; }
#introScrim {
display: block;
background: linear-gradient(to bottom,
rgba(0, 0, 0, 0.82) 0%, rgba(0, 0, 0, 0.35) 22%, rgba(0, 0, 0, 0) 36%,
rgba(0, 0, 0, 0) 60%, rgba(0, 0, 0, 0.45) 80%, rgba(0, 0, 0, 0.88) 100%);
}
}
/* 페이지 스택 */
#stage { position: fixed; inset: 0; overflow: hidden; perspective: 1500px; }
.page {
position: absolute; inset: 0;
opacity: 0; visibility: hidden;
}
/* 레이어 승격은 화면에 올라온 장만. 9장 전부 걸면 안 보이는 것까지 GPU에 물고 있음 */
.page.is-on { visibility: visible; opacity: 1; will-change: opacity, transform; }
.page { pointer-events: none; }
.page.is-front { pointer-events: auto; }
.page { transform-origin: 50% 50%; backface-visibility: hidden; }
/* Footer : 마우스 따라다니는 별 + 글자 흩뿌리기 */
/* ㄴ 캔버스가 글자 위에 깔림. 별이 글자를 쓸고 지나가는 게 보여야 물리적으로 읽힘 */
#footerStage { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
#footerStage canvas { display: block; width: 100%; height: 100%; }
/* inline-block 이어야 transform이 먹음. pre는 어절 사이 공백 안 죽게 */
#fuckfooter .sc-l { display: inline-block; white-space: pre; }
#fuckfooter.is-on .sc-l { will-change: transform; }
/* Index. 페이지 */
#indexTitle {
position: absolute; left: 7vw; top: 26vh; z-index: 2; pointer-events: none;
font-weight: 900; letter-spacing: -0.035em; line-height: 1;
font-size: clamp(2.2rem, 5vw, 4.5rem);
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.85), 0 0 6px rgba(0, 0, 0, 0.7);
opacity: calc(var(--in, 1) * var(--live, 1));
transform: translateY(calc((1 - var(--in, 1)) * -62vh - var(--exit, 0) * 46vh));
will-change: opacity, transform;
}
/* Index. : Drift Wall, Folder */
#indexBox {
display: inline-flex; align-items: baseline; gap: 0.34em;
}
#folderDock { display: inline-block; width: 1.25em; height: 1em; }
#indexScene {
position: absolute; inset: 0; z-index: 1;
opacity: calc(var(--in, 1) * var(--live, 1));
will-change: opacity;
}
#indexFolderLayer {
position: absolute; inset: 0; z-index: 3; pointer-events: none;
opacity: calc(var(--in, 1) * var(--live, 1));
transform: translateY(calc(var(--exit, 0) * var(--fdir, 42) * 1vh));
}
#indexFolder {
position: absolute; left: 0; top: 0; transform-origin: 0 0;
pointer-events: auto; will-change: transform;
}
#indexWall {
position: absolute; inset: 0;
opacity: 0; pointer-events: none;
transform: translateY(calc(var(--exit, 0) * 42vh));
transition: opacity 420ms ease;
}
#indexWall.is-open { opacity: 1; pointer-events: auto; }
/* 카드 : Folder(서류), Drift Wall(타일) 같은 type으로 처리 */
.idx-card {
--cw: 100px;
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: calc(var(--cw) * 0.04);
padding: calc(var(--cw) * 0.06) calc(var(--cw) * 0.08);
background: #d7d7d7; border-radius: inherit; overflow: hidden;
text-align: center; user-select: none;
}
.idx-card__title {
display: inline-block; white-space: nowrap;
font-weight: 700; line-height: 1.15;
font-size: calc(var(--cw) * var(--idx-title-k, 0.065));
padding: 0.28em 0.5em;
background: var(--hl); color: var(--ink);
}
.idx-card__sub {
display: block; white-space: nowrap;
font-weight: 700; line-height: 1.2; color: #3d3d3d;
font-size: calc(var(--cw) * var(--idx-sub-k, 0.045));
}
.paper:nth-child(1) .idx-card { --cw: 70px; }
.paper:nth-child(2) .idx-card { --cw: 80px; }
.paper:nth-child(3) .idx-card { --cw: 90px; }
.drift-wall__inner .idx-card { --cw: var(--dw-tile-w); }
/* Folder */
.folder {
--folder-color: #FFCC7B;
--folder-back-color: #EABB71;
transition: transform 0.2s ease-in;
cursor: pointer;
}
.folder:hover { transform: translateY(-8px); }
.folder:hover .paper { transform: translate(-50%, 0%); }
.folder:hover .folder__front { transform: skew(15deg) scaleY(0.6); }
.folder:hover .right { transform: skew(-15deg) scaleY(0.6); }
.folder.is-emptied .paper { opacity: 0; }
.folder__back {
position: relative; width: 100px; height: 80px;
background: var(--folder-back-color);
border-radius: 0px 10px 10px 10px;
}
.folder__back::after {
position: absolute; z-index: 0; bottom: 98%; left: 0; content: "";
width: 30px; height: 10px;
background: var(--folder-back-color);
border-radius: 5px 5px 0 0;
}
.paper {
position: absolute; z-index: 2; bottom: 10%; left: 50%;
transform: translate(-50%, 10%);
width: 70%; height: 80%;
background: #d7d7d7; border-radius: 10px;
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.paper:nth-child(2) { width: 80%; height: 70%; }
.paper:nth-child(3) { width: 90%; height: 60%; }
.folder__front {
position: absolute; z-index: 3; width: 100%; height: 100%;
background: var(--folder-color);
border-radius: 5px 10px 10px 10px;
transform-origin: bottom;
transition: transform 0.3s ease-in-out;
}
.folder:focus-visible { outline: 2px solid #ffffff; outline-offset: 4px; }
/* Drift Wall */
.drift-wall {
position: relative; width: 100%; height: 100%; overflow: hidden;
perspective: var(--dw-perspective, 1200px);
perspective-origin: 50% 50%;
--dw-tile-w: 200px; --dw-tile-h: 132px; --dw-gap: 18px; --dw-radius: 14px;
--dw-lift: 64px; --dw-dim: 0.55; --dw-overlay: #060010; --dw-edge: 40%;
-webkit-mask-image:
radial-gradient(ellipse 78% 82% at 50% 46%, #000 var(--dw-edge), transparent 100%),
linear-gradient(to top, #000 var(--dw-edge), transparent 100%);
-webkit-mask-composite: source-in;
mask-image:
radial-gradient(ellipse 78% 82% at 50% 46%, #000 var(--dw-edge), transparent 100%),
linear-gradient(to top, #000 var(--dw-edge), transparent 100%);
mask-composite: intersect;
}
.drift-wall__plane {
position: absolute; top: 50%; left: 50%;
display: flex; flex-direction: row;
transform-style: preserve-3d; cursor: pointer;
transform-origin: 50% 50%; will-change: transform;
}
.drift-wall__col {
position: relative;
width: calc(var(--dw-tile-w) + var(--dw-gap));
transform-style: preserve-3d;
}
.drift-wall__track {
display: flex; flex-direction: column;
will-change: transform; transform-style: preserve-3d;
}
.drift-wall__tile {
position: relative; display: block; width: 100%;
height: calc(var(--dw-tile-h) + var(--dw-gap));
flex: 0 0 auto; outline: none; transform-style: preserve-3d;
}
.drift-wall__inner {
position: absolute; inset: calc(var(--dw-gap) / 2);
display: block; border-radius: var(--dw-radius); overflow: hidden;
background: #0b0b12; opacity: var(--dw-dim);
pointer-events: none;
transition:
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.drift-wall__overlay {
position: absolute; inset: 0;
background: var(--dw-overlay); opacity: 0.42;
pointer-events: none;
transition: opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.drift-wall__tile.is-active .drift-wall__inner,
.drift-wall__tile:focus-visible .drift-wall__inner {
opacity: 1;
transform: translateZ(var(--dw-lift));
box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.7);
}
.drift-wall__tile.is-active .drift-wall__overlay,
.drift-wall__tile:focus-visible .drift-wall__overlay { opacity: 0; }
.drift-wall__tile:focus-visible .drift-wall__inner {
box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.7), 0 0 0 2px rgba(255, 255, 255, 0.9);
}
@media (prefers-reduced-motion: reduce) {
.folder, .folder__front, .paper { transition: none; }
.drift-wall__plane, .drift-wall__track, .depth-text__stage { will-change: auto; }
}
#clickSpark { position: fixed; inset: 0; pointer-events: none; z-index: 9999; }
@media (prefers-reduced-motion: reduce) {
.js-reveal [data-reveal], .js-reveal [data-hl] { transition: none; }
.js-reveal [data-reveal] { opacity: 1; filter: none; transform: none; }
[data-hl] { background-size: 100% 100%; color: var(--ink, #000); }
}
/* I think / Feel free to contact. : 글 + 이모지 두 칸 */
/* ㄴ 원래 여백 96px이면 글이 왼쪽 끝에 붙어서 가운데가 비어 보였음. 제목 박스 왼쪽 여백 146px로 고정 */
/* ㄴ 146px은 1422px 화면 기준으로 직접 정한 값. 그보다 좁은 창에선 같은 비율(10.27vw)로 줄어서 이모지랑 안 겹침 */
/* ㄴ 이모지는 Be creative처럼 남은 칸 가운데. 두 칸 사이 gap 없앰(Contact의 lg:gap-16) */
@media (min-width: 1024px) {
#thinkWrap, #contactWrap { padding-left: min(146px, 10.27vw); column-gap: 0; }
}
/* 모바일 전용 부품. 데스크톱에선 안 보임 */
#badge, #indexList, #projectChips { display: none; }
/* 모바일 */
/* ㄴ 767px 이하 + 가로모드 폰(좁고 납작한 화면). JS는 window.compactMQ로 같은 조건 봄 */
/* ㄴ 페이지 한 장씩 넘기는 거 안 씀. 섹션이 문서 흐름대로 쌓이고 그냥 세로로 스크롤 */
/* ㄴ 전엔 데스크톱 2단을 한 화면에 억지로 접어 넣어서 폰마다 잘리고 비율 깨졌음 */
/* ㄴ Tailwind가 head 맨 끝이라 나중에 옴. 여기 선택자는 특이도로 이겨야 함 */
@media (max-width: 767px), (max-width: 1023px) and (max-height: 500px) {
html, body { height: auto; overflow: visible; }
/* clip은 스크롤 컨테이너가 안 돼서 인트로 sticky 안 깨짐. hidden 쓰면 sticky 죽음 */
#stage { position: relative; inset: auto; height: auto; overflow: visible; overflow-x: clip; perspective: none; }
.page {
position: relative; inset: auto;
opacity: 1; visibility: visible; pointer-events: auto;
backface-visibility: visible;
}
.page.is-on { will-change: auto; }
.page .h-screen { height: auto; }
/* 섹션 공통. 좌우 24px, 가로모드는 가운데 좁은 단 */
.page--special > div,
#fuckindex > div {
max-width: 40rem; margin: 0 auto;
padding: 3.25rem 1.5rem;
}
/* 제목 : 여섯 개 전부 같은 크기로 한 줄 */
/* ㄴ 제일 긴 Practice makes perfect.가 여백까지 글자크기의 11.55배. 그거 기준으로 폭에 맞춤 */
.page--special h2[data-hl] {
margin: 0; padding: 0.28em 0.42em;
font-size: min(2.25rem, calc((min(100vw, 40rem) - 3rem) / 11.6));
line-height: 1.15; letter-spacing: -0.02em;
}
/* 본문 글자. 한글이 '찾아갑/니다'처럼 음절에서 끊기지 않게 어절 단위로 줄바꿈 */
#fuckhover .grid p,
#fuckscrollanimation p, #fuckhyperlink p, #fuckmailto p,
#stackHead p, #bentoMain > .flex-shrink-0 > p { font-size: 0.9375rem; line-height: 1.7; word-break: keep-all; overflow-wrap: break-word; }
#fuckscrollanimation h3, #fuckhyperlink h3 { font-size: 1.0625rem; }
/* 인트로 */
/* ㄴ 화면은 sticky로 붙여두고 섹션 길이만큼 스크롤하는 동안 커버가 열림. 진행도는 nav 스크립트 */
/* ㄴ 높이 250svh = 붙어 있는 화면 1장 + 여는 데 쓰는 스크롤 1.5장 */
#fuckintro { height: 250vh; height: 250svh; }
#introStage { position: sticky; top: 0; height: 100vh; height: 100dvh; }
/* 가로모드 폰은 767 규칙을 못 받아서 출처가 오른쪽 끝에 붙음 */
#introQuote { padding: 0 6vw; }
/* Index. : Folder/Drift Wall 대신 누르면 그 섹션으로 내려가는 목록 */
#fuckindex > div { overflow: visible; }
#indexTitle {
position: static; opacity: 1; transform: none; text-shadow: none;
font-size: clamp(2.5rem, 12vw, 3.25rem); margin-bottom: 1.5rem;
}
#indexScene, #indexFolderLayer { display: none; }
/* 데스크톱에서 폴더가 들어가 앉는 자리. 여기선 닫힌 폴더 그림만 */
#folderDock {
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 88.4'%3E%3Crect width='32' height='18' rx='5' fill='%23EABB71'/%3E%3Crect y='8.4' width='100' height='80' rx='10' fill='%23FFCC7B'/%3E%3C/svg%3E") center / contain no-repeat;
}
#indexList { display: grid; gap: 0.75rem; }
.idx-row {
display: flex; align-items: center; gap: 0.875rem;
padding: 0.9rem 1rem; border-radius: 14px;
background: #d7d7d7; color: #3d3d3d;
-webkit-tap-highlight-color: transparent;
}
.idx-row:active { background: #c6c6c6; }
.idx-row__no { min-width: 1.4em; font-weight: 800; font-size: 0.8125rem; color: #8a8a8a; font-variant-numeric: tabular-nums; }
.idx-row__txt { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 0.4rem; }
.idx-row__title {
display: inline-block; white-space: nowrap;
padding: 0.22em 0.45em; background: var(--hl); color: var(--ink);
font-weight: 700; font-size: 1.0625rem; line-height: 1.15;
}
.idx-row__sub { font-weight: 700; font-size: 0.8125rem; line-height: 1.3; }
.idx-row__go { flex: none; width: 1.1rem; height: 1.1rem; }
/* About ME. : 명찰 위, 글 아래 */
#fuckhover > div { display: flex; flex-direction: column; align-items: stretch; gap: 0; padding-top: 0; }
#fuckhover > div > * { flex: none; }
#lanyardCol { display: flex; order: -1; height: auto; justify-content: center; margin-bottom: 1.75rem; }
#lanyardStage { display: none; }
/* 이름이 박스랑 <Residence/> 딱 가운데 오게. 데스크톱이랑 같은 방식으로 글자 윤곽 재서 맞춤 */
/* ㄴ 이름 24/32px면 윤곽 위아래 여백이 5.2px씩, h4 16/24px면 윗여백 5.2px */
/* ㄴ 박스~이름 = 29.2 + 5.2, 이름~<Residence/> = 5.2 + 24 + 5.2. 둘 다 34.4px */
#fuckhover h2 + div { margin-top: 1.825rem; }
#fuckhover h3 { font-size: 1.5rem; line-height: 2rem; }
#fuckhover .grid { margin-top: 1.5rem; grid-template-columns: 1fr; row-gap: 1.1rem; }
#fuckhover .grid > .col-span-2 { grid-column: auto; }
#fuckhover h4 { font-size: 1rem; line-height: 1.5rem; }
/* 명찰 : 모바일용 2D 랜야드 */
/* ㄴ 3D 랜야드는 three 빼고도 rapier + glb 합쳐 850KB라 폰에선 안 받음. 끈이랑 카드만 CSS로 그림 */
/* ㄴ 평소엔 살랑살랑, 옆으로 끌면 따라 기울었다 스프링으로 돌아옴. 처리는 About ME. 아래 스크립트 */
#badge {
display: block; width: 7.5rem;
transform-origin: 50% 0;
animation: badgeSway 5.2s ease-in-out infinite;
touch-action: pan-y; user-select: none; -webkit-user-select: none;
}
#badge .badge-strap {
width: 1.375rem; height: 6.75rem; margin: 0 auto;
background-color: #050505;
background-image: url(static/DSHS.png), url(static/DSHS.png), url(static/DSHS.png);
background-size: 0.875rem; background-repeat: no-repeat;
background-position: 50% 18%, 50% 52%, 50% 86%;
}
/* 끈 끝 금속 고리. 카드 위로 올려서 밑동이 구멍에 걸린 것처럼 */
#badge .badge-clip {
position: relative; z-index: 1;
width: 0.75rem; height: 1.15rem; margin: -2px auto 0;
border: 2px solid #8c8c8c; border-top: 0; border-radius: 0 0 0.5rem 0.5rem;
}
#badge .badge-card {
position: relative; margin-top: -0.55rem;
display: grid; place-items: center;
aspect-ratio: 5 / 7; border-radius: 0.55rem;
background: linear-gradient(165deg, #f0f0f0, #d9d9d9);
box-shadow: 0 1.25rem 2rem -0.9rem rgba(0, 0, 0, 0.8), inset 0 0 0 1px rgba(255, 255, 255, 0.55);
}
/* 끈 꿰는 구멍 */
#badge .badge-card::before {
content: ""; position: absolute; top: 0.4rem; left: 50%;
width: 1.5rem; height: 0.3rem; margin-left: -0.75rem;
border-radius: 99px; background: #a3a3a3; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.35);
}
#badge .badge-card img { width: 80%; pointer-events: none; -webkit-user-drag: none; }
/* I think / Be creative / Feel free to contact. : 한 단, 큰 이모지는 제목 위 아이콘으로 */
#thinkWrap, #fuckhyperlink > div, #contactWrap {
display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 0;
}
#thinkWrap > *, #fuckhyperlink > div > *, #contactWrap > * { flex: none; }
#thinkArt, #createArt, #contactArt { display: flex; order: -1; justify-content: flex-start; margin-bottom: 1rem; }
#thinkArt .text-9xl, #createArt .text-9xl, #contactArt .text-9xl { font-size: 3.25rem; line-height: 1; }
#fuckscrollanimation h2 + div, #fuckhyperlink h2 + div, #contactHead h2 + div { margin-top: 1rem; }
#fuckscrollanimation .space-y-10 > * + *, #fuckhyperlink .space-y-10 > * + * { margin-top: 1.75rem; }
#fuckscrollanimation h3 { margin-bottom: 0.6rem; }
#fuckscrollanimation .space-y-2 > * + * { margin-top: 0.25rem; }
#fuckhyperlink .mb-3 { margin-bottom: 0.6rem; }
/* Practice makes perfect. : 원형 캐러셀 대신 옆으로 미는 두 줄 */
/* ㄴ 줄은 화면 끝까지 붙여야 밀 수 있다는 게 보임. 그래서 이 섹션만 좌우 여백을 머리글에 줌 */
#fuckinteraction > div { display: block; padding-left: 0; padding-right: 0; }
#stackHead { display: block; padding: 0 1.5rem; }
#stackHead .mt-6 { margin-top: 1rem; }
#stackNav { display: none; }
#stackWrap { margin-top: 1.5rem; }
#stackStage {
position: static; display: grid;
grid-auto-flow: column; grid-template-rows: auto auto; grid-auto-columns: 6.5rem;
gap: 1rem 0.75rem; padding: 0.25rem 1.5rem 0.5rem;
overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
scroll-snap-type: x mandatory; scroll-padding: 0 1.5rem;
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2.5rem), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 2.5rem), transparent);
}
#stackStage .stack-item { position: static; gap: 0.5rem; scroll-snap-align: start; transition: none; }
#stackStage .stack-card { width: 100%; aspect-ratio: 7 / 9; border-radius: 14px; }
#stackStage .stack-item > span { font-size: 0.8125rem; line-height: 1.2; text-align: center; white-space: nowrap; }
/* Efforts never betray. : 휠 대신 칩, 보고서 카드는 폰 높이의 2/3 */
/* ㄴ 카드 안 스크롤이 끝나면 페이지로 넘어가게 overscroll은 풀어둠 */
#bentoWrap { display: block; }
#bentoSide { display: none; }
#bentoMain > .flex-shrink-0 > p { margin-top: 1rem; }
#projectChips {
position: relative; display: flex; gap: 0.5rem;
margin: 1.25rem -1.5rem 0; padding: 0 1.5rem;
overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
-webkit-mask-image: linear-gradient(to right, transparent, #000 1.5rem, #000 calc(100% - 1.5rem), transparent);
mask-image: linear-gradient(to right, transparent, #000 1.5rem, #000 calc(100% - 1.5rem), transparent);
}
.proj-chip {
flex: none; padding: 0.45rem 0.9rem; border-radius: 99px;
border: 1px solid #404040; background: #262626; color: #d4d4d4;
font-size: 0.875rem; font-weight: 500; line-height: 1.2; white-space: nowrap;
-webkit-tap-highlight-color: transparent;
}
.proj-chip.is-sel { border-color: #E0A800; background: #E0A800; color: #000; font-weight: 700; }
#projectCardStage { height: 66vh; height: min(66svh, 36rem); margin-top: 0.875rem; }
#projectCardStage .cs-stack { inset: 0; }
/* 폰은 50%면 글자가 8px라 못 읽음. 원래 크기 */
#projectCardStage .gh { zoom: 1; overscroll-behavior: auto; }
/* Feel free to contact. */
#contactHead .mt-6 { margin-top: 1rem; }
/* Footer */
#fuckfooter > div { padding: 5rem 1.5rem 6rem; gap: 0.75rem; }
#fuckfooter p:first-child { font-size: min(2.25rem, calc((100vw - 3rem) / 10.4)); }
#fuckfooter p + p { font-size: 0.875rem; color: #a3a3a3; }
}
@keyframes badgeSway {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(2.4deg); }
75% { transform: rotate(-2.4deg); }
}
@media (prefers-reduced-motion: reduce) {
#badge { animation: none; }
}
/* 터치 기기 : hover가 눌러붙는 것 방지 */
@media (hover: none) {
.folder:hover { transform: none; }
.folder:hover .paper { transform: translate(-50%, 10%); }
.folder:hover .folder__front { transform: none; }
.folder:hover .right { transform: none; }
#stackStage .stack-item:hover .stack-card { filter: none; }
}
</style>
<script>document.documentElement.classList.add('js-reveal');</script>
<!-- Tailwind 빌드 결과 -->
<!-- ㄴ Play CDN(400KB + 브라우저에서 CSS 컴파일) 걷어내고 미리 뽑아둔 것으로 대체 -->
<!-- ㄴ 클래스 추가/삭제하면 node tools/build-tailwind.mjs 재실행 -->
<!-- ㄴ tools/ 는 v2.9.4에서 지움. 필요하면 git checkout 8812236 -- tools 로 꺼내 씀 -->
<!-- ㄴ ?v= 는 그 스크립트가 CSS 내용 해시로 알아서 바꿈. 안 바꾸면 배포 캐시(4시간) 때문에 옛 CSS 붙음 -->
<!-- ㄴ 위치는 head 맨 끝 고정. CDN이 여기에 꽂던 자리라 캐스케이드 순서 걸림 -->
<link rel="stylesheet" href="static/tailwind.css?v=a0095372">
</head>
<body class="bg-neutral-900 text-white">
<!-- Preloader (pro.reactbits.dev/docs/components/preloader) -->
<!-- ㄴ 아래 스크립트들이 각자 몫을 등록하고 전부 끝나야 걷힘. 숫자는 진짜 진행률 -->
<!-- ㄴ 랜야드/푸터 별처럼 원래 늦게 받던 것도 이 뒤에서 다 받음. 페이지 열었는데 빈 칸 뜨는 일 없게 -->
<div id="preloader" role="progressbar" aria-label="Preparing for PERFECT..." aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<!-- PERFECT는 아래 Decrypted Text 스크립트가 글자마다 span으로 쪼갬. 말줄임표는 안 뒤섞음 -->
<p id="preloaderLabel" aria-hidden="true"><span>Preparing</span><span>for</span><span><span id="preloaderDecrypt">PERFECT</span>...</span></p>
<div id="preloaderCount"><span id="preloaderNum">0</span><span class="sign">%</span></div>
<div id="preloaderBar"><div id="preloaderFill"></div></div>
</div>
<noscript><style>#preloader { display: none; }</style></noscript>
<script>
// Preloader 진행률 집계
// ㄴ 등록표(JOBS)에 무게를 미리 박아둠. 큰 파일 받는 동안 숫자가 한 자리에 멈춰 있지 않게 KB 비율
// ㄴ 각 스크립트가 claim()으로 자기 몫을 가져감. DOMContentLoaded까지 아무도 안 가져간 건 그 환경에서 안 쓰는 것이니 뺌
// ㄴ 모듈 스크립트도 DOMContentLoaded 전에 다 돌기 때문에 가능. 단 claim은 첫 await 전에 해야 함
// ㄴ 최소 1초는 보여줌. 캐시 있으면 100ms 만에 끝나서 뭐가 떴는지도 모르고 지나감
(() => {
const root = document.getElementById('preloader');
const num = document.getElementById('preloaderNum');
const fill = document.getElementById('preloaderFill');
if (!root) return;
const MIN_MS = 1000; // 이 전엔 100 안 찍음
const MAX_MS = 20000; // 이 넘으면 덜 받았어도 걷음. 뭔가 영영 안 오면 사이트 자체를 못 봄
const HOLD_MS = 400; // 100% 보여주는 시간
const FADE_MS = 500; // CSS transition과 같아야 함
const TEXT_FADE_AT = 99; // 라벨 흐려지기 시작하는 퍼센트
const TAU = 0.18; // 숫자가 목표를 따라붙는 시정수(s). 작을수록 팍팍 뜀
// 무게 = 대략 KB(gzip). 정확할 필요 없고 비율만
const JOBS = {
document: 250, // HTML, CSS, 이모지, 사진. window load
fonts: 400, // Pretendard 서브셋 6개
three: 260,
particle: 400, // 인트로 사진 + 파티클 빌드
logos: 150, // 스택 로고 26장
rapier: 770,
cardGlb: 80,
lanyard: 200, // 텍스처 + 씬 구성
starGlb: 160, // 데스크톱만
star: 50
};
const jobs = new Map();
const dropped = new Set();
let total = 0;
for (const k in JOBS) total += JOBS[k];
function claim(key) {
const had = jobs.get(key);
if (had) return had.h;
const w = JOBS[key] || 100;
// 표에 없거나 마감 뒤에 온 건 그때 더함
if (!(key in JOBS) || dropped.has(key)) { total += w; dropped.delete(key); }
const j = { w, p: 0, h: null };
j.h = {
progress(p) { if (j.p < 1) j.p = Math.max(j.p, Math.min(1, p)); },
done() { j.p = 1; }
};
jobs.set(key, j);
return j.h;
}
function real() {
let acc = 0;
jobs.forEach(j => { acc += j.w * j.p; });
return total > 0 ? acc / total : 1;
}
addEventListener('DOMContentLoaded', () => {
for (const k in JOBS) if (!jobs.has(k)) { total -= JOBS[k]; dropped.add(k); }
}, { once: true });
// 큰 파일은 여기서 받음. 바이트 단위로 진행률이 나와서 숫자가 실제 속도로 감
// ㄴ 결과는 ArrayBuffer. GLTFLoader.parseAsync에 그대로 넣으면 됨
const assets = new Map();
function asset(key, url) {
if (assets.has(key)) return assets.get(key);
const h = claim(key);
const p = fetch(url).then(async res => {
if (!res.ok) throw new Error(url + ' ' + res.status);
if (!res.body) return res.arrayBuffer();
// Content-Length는 전송(압축) 크기고 got은 푼 크기. gzip 걸리면 1 넘는데 progress()가 잘라줌
const size = +res.headers.get('Content-Length') || 0;
const reader = res.body.getReader();
const parts = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value);
got += value.byteLength;
if (size) h.progress(got / size);
}
const out = new Uint8Array(got);
let at = 0;
for (const c of parts) { out.set(c, at); at += c.byteLength; }
return out.buffer;
});
p.then(h.done, h.done);
assets.set(key, p);
return p;
}
// 문서 자체 + 폰트. 폰트는 load 뒤에 봐야 함. 그 전엔 아직 요청도 안 한 폰트가 있어서 ready가 바로 풀림
const doc = claim('document');
const fonts = claim('fonts');
addEventListener('load', () => {
doc.done();
(document.fonts ? document.fonts.ready : Promise.resolve()).then(fonts.done, fonts.done);
}, { once: true });