docs/static/js/landing.js (7006B)
1 /* 랜딩: 테마 토글·복사 버튼·연출. 연출은 전부 점진적 향상 — Motion이 없거나
2 reduced-motion이면 생략되고, 페이지는 CSS만으로 완성 상태다. */
3 (function () {
4 "use strict";
5
6 /* 테마 토글. docs와 같은 localStorage 키("theme")를 쓰므로 사이트 전체에서
7 선택이 따라다닌다. */
8 var toggle = document.querySelector(".theme-toggle");
9 if (toggle) {
10 toggle.addEventListener("click", function () {
11 var root = document.documentElement;
12 var next = root.dataset.theme === "dark" ? "light" : "dark";
13 root.dataset.theme = next;
14 try { localStorage.setItem("theme", next); } catch (e) {}
15 });
16 }
17
18 /* 설치 한 줄 복사 */
19 /* 복사 버튼은 클래스가 아니라 data-copy-text로 찾는다. 클래스로 찾으면 새
20 버튼마다 아이콘 전용 스타일(.inst-copy는 2rem 정사각형)을 같이 받게 된다. */
21 document.querySelectorAll("[data-copy-text]").forEach(function (btn) {
22 var label = btn.getAttribute("aria-label");
23 /* 글자를 달고 있는 버튼은 그 글자가 바뀌어야 눌린 걸 안다. 아이콘만 있는
24 버튼은 aria-label만 바꾼다 — 볼 게 없으니 들릴 것이라도 바뀌어야 한다. */
25 var text = btn.querySelector(".copy-label");
26 var was = text ? text.textContent : null;
27 btn.addEventListener("click", function () {
28 if (!navigator.clipboard) return;
29 navigator.clipboard.writeText(btn.getAttribute("data-copy-text") || "").then(function () {
30 btn.classList.add("is-done");
31 if (label) btn.setAttribute("aria-label", btn.getAttribute("data-copied"));
32 if (text) text.textContent = btn.getAttribute("data-copied");
33 setTimeout(function () {
34 btn.classList.remove("is-done");
35 if (label) btn.setAttribute("aria-label", label);
36 if (text) text.textContent = was;
37 }, 1600);
38 });
39 });
40 });
41
42 var M = window.Motion;
43 var REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
44 /* 한 번만 판정한다. 도중에 바뀌면 Motion이 관리하던 transform과 우리가 직접
45 쓴 transform이 한 엘리먼트에서 섞이는데, 그 상태는 새로고침이 고친다. */
46 var CAN_ANIMATE = !!(M && M.animate) && !REDUCED;
47 var EASE = [0.16, 1, 0.3, 1];
48
49 /* 설치 채널 전환.
50
51 선택 자체는 radio + CSS가 이미 하고 있다. 표시자는 그 위에 얹는 것이지만
52 **연출이 아니라 상태 표시**다 — 어느 탭이 선택됐는지를 말한다. 그래서
53 reduced-motion이거나 Motion이 아예 없어도 제자리는 찾아가야 하고, 빠지는
54 것은 미끄러짐과 squeeze뿐이다.
55
56 squeeze는 장식이 아니라 이 제품의 동작이다. 히어로의 강조 줄이 옆으로
57 눌려 들어오는 것과 같은 스프링을 쓴다. */
58 var install = document.querySelector(".install");
59 if (install) {
60 var marker = install.querySelector(".pick-marker");
61 var tabs = [].slice.call(install.querySelectorAll(".pick-tab"));
62 var radios = [].slice.call(install.querySelectorAll(".pick-in"));
63 var panels = [].slice.call(install.querySelectorAll(".pick-panel"));
64
65 /* 위치를 잡는 두 경로. 한 엘리먼트에 둘을 섞지 않는다 — Motion은 transform을
66 자기 방식으로 조립하므로, 직접 쓴 transform과 번갈아 쓰면 어긋난다. */
67 var put = function (x, w, animated) {
68 if (!CAN_ANIMATE) {
69 marker.style.width = w + "px";
70 marker.style.transform = "translateX(" + x + "px)";
71 return;
72 }
73 /* x와 scaleX를 따로 준다. transform 문자열을 두 번 애니메이션하면 뒤엣것이
74 앞엣것을 덮어 이동이 사라진다. */
75 if (!animated) {
76 M.animate(marker, { width: w + "px", x: x, scaleX: 1 }, { duration: 0 });
77 return;
78 }
79 /* 이동하는 동안 눌렸다 펴진다. */
80 M.animate(marker, { width: w + "px", x: x }, { duration: 0.34, ease: EASE });
81 M.animate(marker, { scaleX: [0.72, 1] },
82 { type: "spring", stiffness: 260, damping: 18 });
83 };
84
85 var place = function (animated) {
86 var i = radios.findIndex(function (r) { return r.checked; });
87 if (i < 0) return;
88 put(tabs[i].offsetLeft, tabs[i].offsetWidth, animated);
89 install.classList.add("is-enhanced");
90 if (animated && CAN_ANIMATE) {
91 M.animate(panels[i], { opacity: [0, 1], scaleX: [1.05, 1] },
92 { type: "spring", stiffness: 240, damping: 22 });
93 }
94 };
95
96 place(false);
97 radios.forEach(function (r) {
98 r.addEventListener("change", function () { place(true); });
99 });
100 /* 폰트가 늦게 붙으면 탭 폭이 달라진다. 그때 표시자가 어긋난 채로 남는다. */
101 if (document.fonts && document.fonts.ready) {
102 document.fonts.ready.then(function () { place(false); });
103 }
104 window.addEventListener("resize", function () { place(false); });
105 }
106
107 /* 여기부터는 순수한 연출이다. 없어도 페이지는 완성 상태이므로, Motion이
108 없거나 사용자가 모션을 줄여 달라고 했으면 여기서 끝낸다. */
109 if (!CAN_ANIMATE || !M.inView) return;
110
111 /* 히어로 로드 시퀀스 */
112 var heroEls = document.querySelectorAll(".hero [data-reveal]");
113 heroEls.forEach(function (el) { el.style.opacity = "0"; });
114 M.animate(
115 heroEls,
116 { opacity: [0, 1], transform: ["translateY(16px)", "none"] },
117 { duration: 0.6, ease: EASE, delay: M.stagger(0.08) }
118 );
119
120 /* 이름값 하는 연출: 강조 줄이 옆으로 눌려 들어온다(squeeze). 스프링이
121 안 되는 빌드면 조용히 생략 — 등장 자체는 위 시퀀스가 이미 보장한다. */
122 var em = document.querySelector("[data-squeeze]");
123 if (em) {
124 try {
125 M.animate(
126 em,
127 { transform: ["scaleX(1.35)", "scaleX(1)"] },
128 { type: "spring", stiffness: 170, damping: 15, delay: 0.35 }
129 );
130 } catch (e) {}
131 }
132
133 /* 벤토 타일: 뷰포트에 들어올 때 한 번씩 */
134 document.querySelectorAll(".bento-wrap [data-reveal]").forEach(function (el) {
135 el.style.opacity = "0";
136 M.inView(
137 el,
138 function () {
139 M.animate(
140 el,
141 { opacity: [0, 1], transform: ["translateY(14px)", "none"] },
142 { duration: 0.5, ease: EASE }
143 );
144 },
145 { amount: 0.2 }
146 );
147 });
148
149 /* 벤치 막대: 왼쪽에서 자라는 등장. 실척 폭은 SVG 속성이 이미 갖고 있다. */
150 document.querySelectorAll(".row").forEach(function (row) {
151 var bars = row.querySelectorAll("[data-bar]");
152 bars.forEach(function (b) { b.style.transform = "scaleX(0)"; });
153 M.inView(
154 row,
155 function () {
156 M.animate(
157 bars,
158 { transform: ["scaleX(0)", "scaleX(1)"] },
159 { duration: 0.8, ease: EASE, delay: M.stagger(0.1) }
160 );
161 },
162 { amount: 0.5 }
163 );
164 });
165 })();