sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

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 })();