ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit d4f81cca51acf4dc3ffef39a5a016a3f8e969338
parent 2335747957fa73d727cc9d8c7a765203e4df6a98
Author: sqzer <sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 22:40:20 +0900

Give the commands a copy button, and let the setup cross itself out

Clicking the command box grew it by the width of the word copied and then shrank
it again. The button is its own element beside the box now, and the feedback is
the icon swapping to a tick, so nothing moves. The prompt stays in a pseudo-
element, which never reaches the clipboard.

Install reuses the hero's command component instead of a card grid that stretched
an eleven-character command across half the page.

The setup you skip is crossed out a line at a time as the section comes into
view. The rule is a drawn pseudo-element rather than text-decoration, which
cannot be animated, and the markup ships already struck: the script un-strikes it
on load and puts it back, so a reader without JavaScript sees the finished state
rather than four live-looking instructions. Reduced motion strikes all four at
once.

Cut the three paragraphs of fine print under Install. The binary size, the Nerd
Font requirement, the graphics protocols and the platform caveats are all in the
README, which is where someone goes for them.

Diffstat:
Msite/content/_index.md | 3---
Msite/static/css/main.css | 66+++++++++++++++++++++++++++++++++++++++---------------------------
Msite/static/js/status.js | 53++++++++++++++++++++++++++++++++---------------------
Msite/templates/landing.html | 39+++++++++++++++++++--------------------
4 files changed, 90 insertion(+), 71 deletion(-)

diff --git a/site/content/_index.md b/site/content/_index.md
@@ -50,9 +50,6 @@ keys_body_2 = "The mouse works too, when your hand is already there: click a tab
 
 # ── 6. Install ───────────────────────────────────────────────────────────
 install_title  = "Install it."
-install_lede   = "About 7 MB installed, linked against alsa-lib, glibc and gcc-libs and nothing else, and a 5,000-track library opens in about 18 ms once its tags are cached."
-install_note_1 = "Two things to set while you are here. ommp draws its interface with <a href=\"https://www.nerdfonts.com/\">Nerd Font</a> glyphs, so point your terminal at a Nerd Font or the icons arrive as empty boxes. And album art comes through as a real image in terminals that speak Kitty graphics, Sixel or the iTerm2 protocol; ommp works out which at startup and there is nothing for you to configure. Everywhere else the cover is drawn in blocks."
-install_note_2 = "Linux only, said plainly: it is untested anywhere else and does not compile on Windows. It plays what is on your disk, so there is no streaming, no catalogue and no account, and it does not resume the track you were playing last time."
 install_close  = "Then put some music in <code>~/Music</code> and run <code>ommp</code>."
 splash_note    = "The notes behind the top of this page are the ones ommp scatters across its splash screen, generated from the same hash the program uses."
 
diff --git a/site/static/css/main.css b/site/static/css/main.css
@@ -151,25 +151,39 @@ a:hover { color: var(--cyan); }
   color: var(--ink-2); font-size: 1.0625rem;
 }
 
-.installs { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; }
+.installs { display: flex; flex-wrap: wrap; gap: 1rem 1.5rem; justify-content: center; }
+.installs-left { justify-content: flex-start; margin-bottom: 2.5rem; }
 .install { text-align: left; }
 .install-label {
   display: block; font-size: .75rem; letter-spacing: .14em; text-transform: uppercase;
   color: var(--ink-2); margin-bottom: .4rem;
 }
 
+/* The box is sized by the command, and the button sits outside it, so nothing
+   moves when you copy — only the icon changes. */
+.cmdline { display: flex; align-items: stretch; gap: .5rem; }
 .cmd {
-  display: block;
   font-family: var(--mono); font-size: .9375rem;
   background: var(--panel); border: 1px solid var(--line);
   padding: .7rem 1rem; border-radius: 4px;
   color: var(--ink); white-space: nowrap; overflow-x: auto;
 }
+/* The prompt lives in a pseudo-element, which never reaches the clipboard. */
 .cmd::before { content: "$ "; color: var(--dim); }
-.cmd[data-copy] { cursor: pointer; }
-.cmd[data-copy]:hover { border-color: var(--sky); }
-.cmd.is-copied { border-color: var(--playing); }
-.cmd.is-copied::after { content: " copied"; color: var(--playing); }
+
+.cmd-copy {
+  flex: none; display: grid; place-items: center;
+  width: 2.5rem; padding: 0;
+  background: none; border: 1px solid var(--line); border-radius: 4px;
+  color: var(--ink-2); cursor: pointer;
+  transition: color .15s, border-color .15s, background .15s;
+}
+.cmd-copy:hover { color: var(--ink); border-color: var(--sky); background: #ffffff0a; }
+.cmd-copy svg { width: 15px; height: 15px; }
+.cmd-copy svg:last-child { display: none; }
+.cmd-copy.is-copied { color: var(--playing); border-color: var(--playing); }
+.cmd-copy.is-copied svg:first-child { display: none; }
+.cmd-copy.is-copied svg:last-child { display: block; }
 
 .hero-meta { margin-top: 2rem; color: var(--ink-2); font-size: .875rem; }
 
@@ -243,10 +257,24 @@ a:hover { color: var(--cyan); }
 .setup { font-size: .9375rem; }
 .setup-skipped { list-style: none; margin: 0 0 1.5rem; padding: 0; display: grid; gap: .7rem; }
 .setup-skipped li {
+  position: relative; width: max-content; max-width: 100%;
   color: var(--dim);
-  text-decoration: line-through;
-  text-decoration-color: #FF329699;
-  text-decoration-thickness: 1px;
+}
+.setup-skipped li::after {
+  content: ""; position: absolute; inset-inline: 0; top: .62em;
+  height: 1px; background: var(--pink);
+  transform-origin: left;
+}
+
+/* Once the script is running the lines arrive unstruck and are crossed out one
+   at a time as the section comes into view. Without it they are simply already
+   crossed out, which is the state that makes the point. */
+.setup-armed .setup-skipped li { color: var(--ink-2); transition: color .4s ease; }
+.setup-armed .setup-skipped li::after { transform: scaleX(0); transition: transform .45s ease; }
+.setup-armed .setup-skipped li.is-struck { color: var(--dim); }
+.setup-armed .setup-skipped li.is-struck::after { transform: scaleX(1); }
+@media (prefers-reduced-motion: reduce) {
+  .setup-armed .setup-skipped li::after { transition: none; }
 }
 .setup-real {
   margin: 0; font-weight: 700; font-size: 1.5rem;
@@ -292,25 +320,9 @@ kbd {
   display: inline-block; text-align: center;
 }
 
-/* ── Install grid ─────────────────────────────────────────────────── */
-
-.install-grid {
-  display: grid; gap: 1rem; margin-bottom: 2rem;
-  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
-}
-/* The clone URL is longer than a third of the wrap. Scrolling it inside the
-   card hides the end behind no affordance at all, so wrap it instead — the
-   whole string is what gets copied either way. Wrapping makes that one box two
-   lines tall, so the boxes are stretched to a common height rather than left
-   with one hanging 24px below its neighbours. */
-.install-card { display: grid; grid-template-rows: auto 1fr; }
-.install-card .cmd { white-space: normal; overflow-wrap: anywhere; height: 100%; }
+/* ── Install ──────────────────────────────────────────────────────── */
 
-.install-close { margin: 0 0 2.5rem; font-size: 1.0625rem; }
-/* Everything a person about to type the command needs to know, at the size that
-   says it is not the reason they came. */
-.fine { max-width: 46rem; margin: 0 0 1rem; color: var(--ink-2); font-size: .875rem; }
-.fine:last-child { margin-bottom: 0; }
+.install-close { margin: 0; font-size: 1.0625rem; }
 
 /* ── Footer ───────────────────────────────────────────────────────── */
 
diff --git a/site/static/js/status.js b/site/static/js/status.js
@@ -48,29 +48,40 @@
   addEventListener("resize", onScroll);
   update();
 
-  // Click a command to copy it — the page is mostly commands. The button role
-  // and the tab stop are set here rather than in the markup: copying is only
-  // possible once this file has run, so without it these are plain text and
-  // should not claim to be anything else.
-  document.querySelectorAll("[data-copy]").forEach(function (el) {
-    el.tabIndex = 0;
-    el.setAttribute("role", "button");
-
-    function copy() {
+  // Copy a command. The button is its own element beside the box, so the box
+  // never resizes — the icon swaps and that is the whole feedback.
+  document.querySelectorAll(".cmd-copy").forEach(function (btn) {
+    var cmd = btn.parentNode.querySelector(".cmd");
+    if (!cmd) return;
+    btn.addEventListener("click", function () {
       if (!navigator.clipboard) return;
-      navigator.clipboard.writeText(el.textContent.trim()).then(function () {
-        el.classList.add("is-copied");
-        setTimeout(function () { el.classList.remove("is-copied"); }, 1400);
+      // textContent, so the "$ " prompt drawn by CSS is left behind.
+      navigator.clipboard.writeText(cmd.textContent.trim()).then(function () {
+        btn.classList.add("is-copied");
+        setTimeout(function () { btn.classList.remove("is-copied"); }, 1600);
       });
-    }
-
-    el.addEventListener("click", copy);
-    el.addEventListener("keydown", function (e) {
-      // What a real button answers to.
-      if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
-        e.preventDefault();
-        copy();
-      }
     });
   });
+
+  // The setup you skip crosses itself out as you reach it. The markup ships
+  // already struck, so this first un-strikes it and then puts the lines back
+  // one at a time; if the script never runs, the finished state is what shows.
+  var setup = document.querySelector(".setup-skipped");
+  if (setup && "IntersectionObserver" in window) {
+    var lines = Array.prototype.slice.call(setup.children);
+    setup.parentNode.classList.add("setup-armed");
+
+    var still = matchMedia("(prefers-reduced-motion: reduce)").matches;
+    var strike = new IntersectionObserver(function (entries) {
+      entries.forEach(function (e) {
+        if (!e.isIntersecting) return;
+        strike.unobserve(e.target);
+        lines.forEach(function (li, i) {
+          setTimeout(function () { li.classList.add("is-struck"); }, still ? 0 : i * 260);
+        });
+      });
+    }, { threshold: 0.6 });
+
+    strike.observe(setup);
+  }
 })();
diff --git a/site/templates/landing.html b/site/templates/landing.html
@@ -1,4 +1,18 @@
 {%- set x = page.extra -%}
+{#- A command and a button that copies it. The button is separate so the box
+    never changes size: the icon swaps, the command does not move. -#}
+{%- macro command(label, text) -%}
+      <div class="install">
+        <span class="install-label">{{ label }}</span>
+        <div class="cmdline">
+          <code class="cmd">{{ text }}</code>
+          <button class="cmd-copy" type="button" aria-label="Copy {{ text }}">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2.5"/><path d="M5 15H4.5A1.5 1.5 0 0 1 3 13.5v-9A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5V5"/></svg>
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 5 5 9-11"/></svg>
+          </button>
+        </div>
+      </div>
+{%- endmacro -%}
 <!doctype html>
 <html lang="en">
 <head>
@@ -40,14 +54,8 @@
     <p class="lede">{{ x.lede }}</p>
 
     <div class="installs">
-      <div class="install">
-        <span class="install-label">{{ x.install_primary_label }}</span>
-        <code class="cmd" data-copy>{{ x.install_primary }}</code>
-      </div>
-      <div class="install">
-        <span class="install-label">{{ x.install_secondary_label }}</span>
-        <code class="cmd" data-copy>{{ x.install_secondary }}</code>
-      </div>
+{{ command(x.install_primary_label, x.install_primary) }}
+{{ command(x.install_secondary_label, x.install_secondary) }}
     </div>
 
     <p class="hero-meta">
@@ -162,21 +170,12 @@
   <div class="wrap">
     <h2 class="sec-title">{{ x.install_title }}</h2>
 
-    <div class="install-grid">
-      <div class="install-card">
-        <span class="install-label">{{ x.install_primary_label }}</span>
-        <code class="cmd" data-copy>{{ x.install_primary }}</code>
-      </div>
-      <div class="install-card">
-        <span class="install-label">{{ x.install_secondary_label }}</span>
-        <code class="cmd" data-copy>{{ x.install_secondary }}</code>
-      </div>
+    <div class="installs installs-left">
+{{ command(x.install_primary_label, x.install_primary) }}
+{{ command(x.install_secondary_label, x.install_secondary) }}
     </div>
 
     <p class="install-close">{{ x.install_close | safe }}</p>
-    <p class="fine">{{ x.install_lede }}</p>
-    <p class="fine">{{ x.install_note_1 | safe }}</p>
-    <p class="fine">{{ x.install_note_2 }}</p>
   </div>
 </section>