sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 2b92dabed8ba5bf5501184232733e68458ef2ff9
parent 455c1a70dadbd8c083c5c1a6c9b6577ca76328ab
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 00:29:19 +0900

Rebuild the install picker as one card with a sliding, squeezing marker

The previous pass put three pills above a floating command box. Two problems:
the pills and the box read as separate objects with only proximity to say one
drives the other, and the box resized to whatever command was showing, so the
copy button jumped roughly 300px between the AUR and cargo tabs. A control that
moves that far when you switch is a control you have to re-find.

Now it is one card: tab row, hairline divider, command. The panels are stacked
in a single grid cell rather than toggled with `display`, so the card is always
as wide as the longest command and nothing reflows on switch, and the copy
button is pinned to the right edge so it stays where you left it.

The motion is the site's own verb. This project is named for squeezing and the
hero already springs its emphasis line in on scaleX; the marker now does the
same thing — it compresses toward the direction of travel and releases into
place, and the incoming command expands from slightly over-wide. Reusing that
spring rather than inventing an easing keeps the page speaking one language.

Marker movement is progressive enhancement layered on top of the radio + CSS
selection that already worked: without JavaScript the checked label keeps a
tinted background, and `is-enhanced` hands that job to the marker only once the
script has measured the tabs. Position is recalculated after webfonts settle
and on resize, because tab widths change under both.

`x` and `scaleX` are animated as separate keys — animating the `transform`
string twice would have let the spring overwrite the translation and the marker
would never move.

Verified all three tabs in both themes at 1100px and 500px, and confirmed in the
rendered DOM that the marker lands on the checked tab.

Diffstat:
Mdocs/static/css/landing.css | 106++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mdocs/static/js/landing.js | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/templates/landing.html | 3+++
3 files changed, 118 insertion(+), 39 deletion(-)

diff --git a/docs/static/css/landing.css b/docs/static/css/landing.css
@@ -324,21 +324,30 @@ main { display: block; }
   color: transparent;
 }
 
-/* 설치 한 줄 — 페이지에서 제일 중요한 것. CTA 바로 아래에서 눈에 띈다. */
-/* 채널 선택 + 명령. fieldset이라 기본 테두리·여백을 먼저 지운다.
-   align-items: flex-start라서 탭 행도 명령 상자도 제 내용만큼만 넓어진다. */
+/* 설치 명령 — 페이지에서 제일 중요한 것. CTA 바로 아래에서 눈에 띈다.
+
+   탭과 명령을 카드 하나로 묶는다. 둘을 떼어 놓으면 화면에 떠 있는 조각이
+   둘이 되고, 무엇이 무엇을 바꾸는지가 근접성으로만 설명된다. */
 .install {
-  display: flex;
+  display: inline-flex;
   flex-direction: column;
-  align-items: flex-start;
-  gap: 0.5rem;
+  align-items: stretch;
   max-width: 100%;
-  border: 0;
+  border: 1px solid var(--line);
+  border-radius: 15px;
+  background: var(--bg-hi);
   padding: 0;
   margin: 1.6rem 0 0;
   min-width: 0;
+  overflow: hidden;
+}
+.pick-tabs {
+  position: relative;
+  display: flex;
+  gap: 0.15rem;
+  padding: 0.32rem;
+  border-bottom: 1px solid var(--line);
 }
-.pick-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; }
 .pick-in {
   position: absolute;
   width: 1px;
@@ -347,54 +356,73 @@ main { display: block; }
   pointer-events: none;
 }
 .pick-tab {
+  position: relative;
+  z-index: 1;
   cursor: pointer;
   font-family: var(--mono);
-  font-size: 0.82rem;
+  font-size: 0.8rem;
   line-height: 1;
   color: var(--muted);
-  padding: 0.42rem 0.72rem;
-  border: 1px solid var(--line);
-  border-radius: 999px;
-  background: transparent;
-  transition: color 0.15s, border-color 0.15s, background 0.15s;
-}
-.pick-tab:hover { color: var(--ink); border-color: var(--ink-3); }
-.pick-in:checked + .pick-tab {
-  color: var(--accent-ink);
-  border-color: rgba(255, 140, 90, 0.55);
-  background: rgba(255, 120, 70, 0.1);
+  padding: 0.44rem 0.8rem;
+  border-radius: 9px;
+  transition: color 0.18s;
 }
+.pick-tab:hover { color: var(--ink); }
+.pick-in:checked + .pick-tab { color: var(--accent-ink); }
+/* JS가 없을 때의 선택 표시. 표시자가 살아나면 이 배경은 넘겨준다. */
+.pick-in:checked + .pick-tab { background: rgba(255, 120, 70, 0.12); }
+.install.is-enhanced .pick-in:checked + .pick-tab { background: transparent; }
 /* 라디오가 화면에 없으므로 포커스 링은 라벨이 받는다. 없으면 키보드로 못 쓴다. */
 .pick-in:focus-visible + .pick-tab {
   outline: 2px solid var(--accent);
   outline-offset: 2px;
 }
+/* 선택을 따라 미끄러지는 표시자. 이동하는 동안 진행 방향으로 눌렸다 펴진다 —
+   히어로의 squeeze와 같은 동작이고, 이 제품이 하는 일 자체다. */
+.pick-marker {
+  position: absolute;
+  top: 0.32rem;
+  left: 0;
+  height: calc(100% - 0.64rem);
+  border-radius: 9px;
+  background: rgba(255, 120, 70, 0.14);
+  box-shadow: inset 0 0 0 1px rgba(255, 140, 90, 0.4);
+  opacity: 0;
+  pointer-events: none;
+  transform-origin: center;
+}
+.install.is-enhanced .pick-marker { opacity: 1; }
 
+/* 패널을 격자 한 칸에 겹쳐 쌓는다. 카드 폭이 가장 긴 명령에 맞춰 고정되므로
+   탭을 옮길 때마다 상자가 늘었다 줄었다 하지 않는다.
+   숨김은 display가 아니라 visibility다 — 폭 계산에는 남아 있어야 한다. */
+.pick-panels {
+  display: grid;
+  min-width: 0;
+  max-width: 100%;
+}
 .pick-panel {
-  display: none;
+  grid-area: 1 / 1;
+  display: flex;
   align-items: center;
-  gap: 0.4rem;
+  gap: 0.6rem;
   min-width: 0;
-  max-width: 100%;
-  border: 1px solid rgba(255, 140, 90, 0.45);
-  border-radius: 13px;
-  background: var(--bg-hi);
-  box-shadow: 0 4px 22px rgba(255, 110, 60, 0.16);
-  padding: 0.72rem 0.55rem 0.72rem 1.05rem;
+  padding: 0.78rem 0.6rem 0.78rem 1.05rem;
+  visibility: hidden;
+  opacity: 0;
+  transform-origin: left center;
 }
-/* 패널만 따로 감싼 건 `nth-child`를 정확히 세기 위해서다. fieldset 바로 아래에
-   두면 legend와 탭 행까지 형제로 세어 한 칸씩 밀린다 — 실제로 그렇게 밀려서
-   AUR 탭에 cargo 명령이 나왔다.
-
-   첫 패널이 기본이다. `:has()`를 모르는 브라우저에서도 curl 명령은 그대로
+/* 복사 버튼은 카드 오른쪽 끝에 붙는다. 명령 길이를 따라가면 탭을 옮길 때마다
+   버튼이 300px씩 이동하는데, 누르려던 것이 도망가는 셈이다. */
+.pick-panel > .inst-copy { margin-left: auto; }
+/* 첫 패널이 기본이다. `:has()`를 모르는 브라우저에서도 curl 명령은 그대로
    보이고 탭만 안 먹는다 — 이 페이지에서 가장 중요한 내용이 사라지면 안 된다.
    채널이 셋인 건 packaging/README.md의 결정이고, 늘리면 여기도 는다. */
-.pick-panels { min-width: 0; max-width: 100%; }
-.pick-panel:first-child { display: flex; }
-.install:has(#inst-2:checked) .pick-panel { display: none; }
-.install:has(#inst-2:checked) .pick-panel:nth-child(2) { display: flex; }
-.install:has(#inst-3:checked) .pick-panel { display: none; }
-.install:has(#inst-3:checked) .pick-panel:nth-child(3) { display: flex; }
+.pick-panel:first-child { visibility: visible; opacity: 1; }
+.install:has(#inst-2:checked) .pick-panel,
+.install:has(#inst-3:checked) .pick-panel { visibility: hidden; opacity: 0; }
+.install:has(#inst-2:checked) .pick-panel:nth-child(2),
+.install:has(#inst-3:checked) .pick-panel:nth-child(3) { visibility: visible; opacity: 1; }
 
 .install-note { margin: 0.7rem 0 0; font-size: 0.82rem; color: var(--muted); }
 
diff --git a/docs/static/js/landing.js b/docs/static/js/landing.js
@@ -37,6 +37,54 @@
 
   var EASE = [0.16, 1, 0.3, 1];
 
+  /* 설치 채널 전환.
+
+     선택 자체는 radio + CSS가 이미 하고 있다. 여기서 더하는 건 두 가지뿐이다 —
+     선택을 따라 미끄러지는 표시자와, 명령이 바뀔 때의 squeeze. 둘 다 없어도
+     기능은 그대로다(is-enhanced를 붙이기 전까지 CSS 배경이 선택을 나타낸다).
+
+     squeeze는 장식이 아니라 이 제품의 동작이다. 히어로의 강조 줄이 옆으로
+     눌려 들어오는 것과 같은 스프링을 쓴다. */
+  var install = document.querySelector(".install");
+  if (install) {
+    var marker = install.querySelector(".pick-marker");
+    var tabs = [].slice.call(install.querySelectorAll(".pick-tab"));
+    var radios = [].slice.call(install.querySelectorAll(".pick-in"));
+    var panels = [].slice.call(install.querySelectorAll(".pick-panel"));
+
+    var place = function (animated) {
+      var i = radios.findIndex(function (r) { return r.checked; });
+      if (i < 0) return;
+      var tab = tabs[i];
+      var x = tab.offsetLeft;
+      var w = tab.offsetWidth;
+      /* x와 scaleX를 따로 준다. transform 문자열을 두 번 애니메이션하면 뒤엣것이
+         앞엣것을 덮어 이동이 사라진다. */
+      if (!animated) {
+        M.animate(marker, { width: w + "px", x: x, scaleX: 1 }, { duration: 0 });
+        install.classList.add("is-enhanced");
+        return;
+      }
+      /* 이동하는 동안 눌렸다 펴진다. */
+      M.animate(marker, { width: w + "px", x: x }, { duration: 0.34, ease: EASE });
+      M.animate(marker, { scaleX: [0.72, 1] },
+        { type: "spring", stiffness: 260, damping: 18 });
+
+      M.animate(panels[i], { opacity: [0, 1], scaleX: [1.05, 1] },
+        { type: "spring", stiffness: 240, damping: 22 });
+    };
+
+    place(false);
+    radios.forEach(function (r) {
+      r.addEventListener("change", function () { place(true); });
+    });
+    /* 폰트가 늦게 붙으면 탭 폭이 달라진다. 그때 표시자가 어긋난 채로 남는다. */
+    if (document.fonts && document.fonts.ready) {
+      document.fonts.ready.then(function () { place(false); });
+    }
+    window.addEventListener("resize", function () { place(false); });
+  }
+
   /* 히어로 로드 시퀀스 */
   var heroEls = document.querySelectorAll(".hero [data-reveal]");
   heroEls.forEach(function (el) { el.style.opacity = "0"; });
diff --git a/docs/templates/landing.html b/docs/templates/landing.html
@@ -89,6 +89,9 @@
       <fieldset class="install" data-reveal>
         <legend class="sr-only">{{ page.extra.install_pick }}</legend>
         <div class="pick-tabs">
+          {#- JS가 움직이는 표시자. 스크립트가 없으면 라벨 자신의 배경이 선택을
+              나타내고 이건 숨어 있다 — 선택 상태가 JS에 기대면 안 된다. #}
+          <span class="pick-marker" aria-hidden="true"></span>
           {%- for m in page.extra.install %}
           <input class="pick-in" type="radio" name="install-channel" id="inst-{{ loop.index }}"{% if loop.first %} checked{% endif %}>
           <label class="pick-tab" for="inst-{{ loop.index }}">{{ m.label }}</label>