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 a1f1b09da51f16ff9ac87619d8205ec96b78b1df
parent b316a9813b04c64fa287a4cb96219b142cddb767
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 19:24:00 +0900

Add the landing page, and refresh what the README got wrong

site/ is a sqzass project that builds the page at sqzer-x.github.io/ommp.
Every colour in the stylesheet is lifted from the application's own source —
BG_DARK, the logo's pink-to-cyan gradient, the progress bar's violet, and one
accent per tab — so the page cannot drift away from the thing it advertises.
Prose is set in Bricolage Grotesque and anything that is literally the app
(commands, keys, tab names, the status bar) in JetBrains Mono, the font ommp
is designed for. Both are self-hosted latin subsets, 128 KB in total.

The screenshots are new. The one in the README dated from February and showed
a UI that no longer exists: identical borders on every pane before focus was
made visible, a nine-field info panel where there are now thirteen, and the
tab colours before they were fixed. site/tools/shoot.sh reproduces them —
Ghostty is the only terminal installed here that speaks a graphics protocol,
so it is the only way to capture real album art rather than the block-character
fallback.

Three README claims had gone stale: album art has not been read from a
directory scan since it started coming out of the file's own tags, the
bottom-right pane renders track info rather than lyrics, and the crate is
installable from crates.io and the AUR now rather than only from git.

Also widens the help modal's key column, which was exactly as wide as
"Ctrl+drag border" and so left no gap before the description.

Diffstat:
M.gitignore | 1+
MREADME.md | 14++++++++++----
Massets/screenshot.png | 0binary
Asite/content/_index.md | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/sqzass.toml | 16++++++++++++++++
Asite/static/css/main.css | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/static/fonts/bricolage-grotesque-var.woff2 | 0binary
Asite/static/fonts/jetbrains-mono-400.woff2 | 0binary
Asite/static/fonts/jetbrains-mono-700.woff2 | 0binary
Asite/static/img/01-main.png | 0binary
Asite/static/img/02-help.png | 0binary
Asite/static/img/03-search.png | 0binary
Asite/static/img/logo.png | 0binary
Asite/static/js/status.js | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/templates/landing.html | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/templates/page.html | 19+++++++++++++++++++
Asite/tools/prep-state.py | 9+++++++++
Asite/tools/shoot.sh | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/widgets/help_modal.rs | 2+-
19 files changed, 833 insertion(+), 5 deletion(-)

diff --git a/.gitignore b/.gitignore
@@ -1,3 +1,4 @@
 /target
 SPECIFICATION.md
 .claude/
+/site/public/
diff --git a/README.md b/README.md
@@ -65,10 +65,16 @@ PulseAudio or ALSA is required for audio output on Linux.
 
 ## Install
 
-Make sure you have [Rust](https://rustup.rs/) installed, then run:
+On Arch Linux:
 
 ```bash
-cargo install --git https://github.com/sqzer-x/ommp
+yay -S ommp
+```
+
+Anywhere else, with [Rust](https://rustup.rs/) installed:
+
+```bash
+cargo install ommp
 ```
 
 That's it. Now you can run `ommp` from anywhere:
@@ -106,7 +112,7 @@ cargo build --release
 
 Place your music files in `~/Music` and run `ommp`. It will automatically scan all files and subdirectories.
 
-Album art is detected automatically — just place `cover.jpg`, `folder.jpg`, `front.jpg`, or any image file in the same directory as your tracks.
+Album art comes from the audio files themselves — whatever cover is embedded in the tags. Nothing to place alongside them.
 
 ### Browsing
 
@@ -114,7 +120,7 @@ The screen is split into three panels:
 
 - **Left** — Library browser (artists, albums, genres, etc. depending on the active tab)
 - **Center** — Play queue
-- **Right** — Album art / Clock (top) + Lyrics (bottom)
+- **Right** — Album art / Clock (top) + Track info (bottom)
 
 Switch browsing modes using the tabs at the top: Queue, Directories, Artists, Albums, Genre, Format, Playlists
 
diff --git a/assets/screenshot.png b/assets/screenshot.png
Binary files differ.
diff --git a/site/content/_index.md b/site/content/_index.md
@@ -0,0 +1,107 @@
++++
+title = "ommp — Oh My Music Player"
+description = "A terminal music player written in Rust. No MPD, no daemon — point it at ~/Music and press play."
+template = "landing.html"
+
+[extra]
+kicker    = "Terminal music player"
+headline  = "Point it at ~/Music."
+headline2 = "Press play."
+lede = "No MPD. No daemon. No config file. One binary that reads your tags, draws your album art in the terminal, and picks up where you left off."
+
+install_primary_label = "Arch Linux"
+install_primary       = "yay -S ommp"
+install_secondary_label = "Anywhere else"
+install_secondary       = "cargo install ommp"
+
+shot_main   = "img/01-main.png"
+shot_help   = "img/02-help.png"
+shot_search = "img/03-search.png"
+
+# Each feature is keyed to the accent colour its tab actually uses in
+# src/ui/widgets/tab_bar.rs, so the page and the app agree.
+[[extra.features]]
+name  = "Queue"
+color = "#64DCFF"
+body  = "Everything queues by default. Add an album without losing what is playing, drop a track with <code>d</code>, clear it with <code>c</code>."
+
+[[extra.features]]
+name  = "Directories"
+color = "#78FFB4"
+body  = "Browse the folders you actually organised, not a database's idea of them."
+
+[[extra.features]]
+name  = "Artists"
+color = "#FFB464"
+body  = "Grouped by album artist where it is tagged, track artist where it is not. Untagged files land under one heading instead of vanishing."
+
+[[extra.features]]
+name  = "Albums"
+color = "#C882FF"
+body  = "Paired with their artist, so two records called <em>Greatest Hits</em> stay two records."
+
+[[extra.features]]
+name  = "Genre"
+color = "#FF7896"
+body  = "Whatever your tags say. ommp reads them, it does not invent them."
+
+[[extra.features]]
+name  = "Format"
+color = "#78C8FF"
+body  = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC, WMA — with a count beside each."
+
+[[extra.features]]
+name  = "Playlists"
+color = "#FFDC64"
+body  = "Create, rename, star a track with <code>b</code>. Saved between sessions, and entries survive a drive that was not mounted."
+
+[[extra.numbers]]
+value = "18 ms"
+label = "to open a 5,000-track library"
+note  = "Tags are cached and re-read only when a file's timestamp or size changes. The first scan runs across every core."
+
+[[extra.numbers]]
+value = "0.3 ms"
+label = "per frame at 50,000 tracks"
+note  = "Flat, not linear — the browse views are built once when the library loads, not on every redraw."
+
+[[extra.numbers]]
+value = "7 MB"
+label = "one binary, three libraries"
+note  = "Links against alsa-lib, glibc and gcc-libs. Nothing to configure, nothing running in the background."
+
+[[extra.keys]]
+key  = "Space"
+desc = "Play / pause"
+[[extra.keys]]
+key  = "n / N"
+desc = "Next / previous"
+[[extra.keys]]
+key  = "+ / -"
+desc = "Volume"
+[[extra.keys]]
+key  = "s / r"
+desc = "Shuffle / repeat"
+[[extra.keys]]
+key  = "1–7"
+desc = "Switch tab"
+[[extra.keys]]
+key  = "Tab"
+desc = "Cycle pane focus"
+[[extra.keys]]
+key  = "Ctrl+S"
+desc = "Search"
+[[extra.keys]]
+key  = "Ctrl+H"
+desc = "Every other key"
+
+[[extra.protocols]]
+name  = "Kitty graphics"
+terms = "Ghostty, Kitty, WezTerm, Konsole"
+[[extra.protocols]]
+name  = "Sixel"
+terms = "foot, WezTerm, xterm, Contour"
+[[extra.protocols]]
+name  = "iTerm2"
+terms = "WezTerm, Konsole"
++++
diff --git a/site/sqzass.toml b/site/sqzass.toml
@@ -0,0 +1,16 @@
+title       = "ommp"
+description = "Oh My Music Player — a terminal music player written in Rust. No MPD, no daemon."
+
+# The whole URL, path included: sqzass splits this into an origin and a base
+# path, and prefixes every page and asset URL with the path. Serving from a
+# repository subpath needs nothing else.
+base_url = "https://sqzer-x.github.io/ommp"
+
+[search]
+# One page. A full-text index nobody fetches is just bytes.
+enabled = false
+
+[highlight]
+# The install commands are styled by hand in the template, so the generated
+# syntax stylesheet would go unused.
+enabled = false
diff --git a/site/static/css/main.css b/site/static/css/main.css
@@ -0,0 +1,307 @@
+/* ommp landing page.
+ *
+ * Every colour here is lifted from the application's own source rather than
+ * invented, so the page and the thing it is advertising cannot drift apart.
+ * The comments name where each one lives. */
+
+/* Relative URLs, not root-absolute: this file is served from a repository
+   subpath and the stylesheet's own name is content-hashed, but ../fonts/
+   resolves correctly either way. */
+@font-face {
+  font-family: "JetBrains Mono";
+  src: url("../fonts/jetbrains-mono-400.woff2") format("woff2");
+  font-weight: 400; font-style: normal; font-display: swap;
+}
+@font-face {
+  font-family: "JetBrains Mono";
+  src: url("../fonts/jetbrains-mono-700.woff2") format("woff2");
+  font-weight: 700; font-style: normal; font-display: swap;
+}
+@font-face {
+  font-family: "Bricolage Grotesque";
+  src: url("../fonts/bricolage-grotesque-var.woff2") format("woff2");
+  font-weight: 400 800; font-style: normal; font-display: swap;
+}
+
+:root {
+  /* about_modal.rs */
+  --bg:      #0A051E;   /* BG_DARK */
+  --pink:    #FF3296;   /* NEON_PINK, the logo gradient's start */
+  --cyan:    #00FFFF;   /* NEON_CYAN, its end */
+  /* theme.rs */
+  --sky:     #64DCFF;   /* border_focused, and the current track */
+  --dim:     #46505F;   /* border_unfocused */
+  --violet:  #C850FF;   /* progress_filled */
+  /* status_bar.rs */
+  --playing: #50FF78;
+
+  --ink:     #EDEAF6;
+  --ink-2:   #A9A6BC;
+  --panel:   #120B2E;
+  --line:    #241945;
+
+  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  --disp: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
+
+  --wrap: 68rem;
+  --sb-h: 3.25rem;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+@media (prefers-reduced-motion: reduce) {
+  html { scroll-behavior: auto; }
+  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
+}
+
+body {
+  margin: 0;
+  padding-bottom: var(--sb-h);
+  background: var(--bg);
+  color: var(--ink);
+  font-family: var(--disp);
+  font-size: clamp(1rem, .95rem + .2vw, 1.125rem);
+  line-height: 1.6;
+  -webkit-font-smoothing: antialiased;
+}
+
+/* The terminal face is reserved for things that are literally the app:
+   commands you type, keys you press, tab names, the status bar. */
+code, kbd, .cmd, .kicker, .install-label, .feature-name,
+.number-label, .protocols th, .sb-row { font-family: var(--mono); }
+
+a { color: var(--sky); text-underline-offset: .2em; text-decoration-thickness: 1px; }
+a:hover { color: var(--cyan); }
+:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; border-radius: 2px; }
+
+.skip {
+  position: absolute; left: -9999px;
+  background: var(--cyan); color: var(--bg); padding: .6rem 1rem; font-weight: 700;
+}
+.skip:focus { left: 1rem; top: 1rem; z-index: 100; }
+
+.wrap { width: min(100% - 2.5rem, var(--wrap)); margin-inline: auto; }
+
+/* ── Hero ─────────────────────────────────────────────────────────── */
+
+.hero {
+  position: relative;
+  padding-block: clamp(3rem, 8vw, 6rem) 0;
+  /* The splash screen's scattered-note field, reduced to two soft washes. */
+  background:
+    radial-gradient(60rem 32rem at 18% -8%, #3C145055, transparent 70%),
+    radial-gradient(52rem 30rem at 88% 4%,  #143C5055, transparent 70%);
+}
+.hero-in { text-align: center; }
+
+.hero-logo {
+  width: min(100%, 26rem); height: auto; display: block; margin: 0 auto 2.5rem;
+  animation: rise .6s ease-out both;
+}
+@keyframes rise { from { opacity: 0; transform: translateY(.5rem); } to { opacity: 1; transform: none; } }
+
+.kicker {
+  margin: 0 0 1rem;
+  font-size: .8125rem; letter-spacing: .22em; text-transform: uppercase;
+  color: var(--ink-2);
+}
+
+.display {
+  font-family: var(--disp);
+  font-weight: 700;
+  font-size: clamp(2.4rem, 1.4rem + 4.4vw, 4.6rem);
+  line-height: 1.02;
+  letter-spacing: -.03em;
+  margin: 0 0 1.5rem;
+}
+.display em {
+  font-style: normal;
+  background: linear-gradient(100deg, var(--pink), var(--cyan));
+  -webkit-background-clip: text; background-clip: text; color: transparent;
+}
+
+.lede {
+  max-width: 46rem; margin: 0 auto 2.5rem;
+  color: var(--ink-2); font-size: 1.0625rem;
+}
+
+.installs { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; }
+.install { text-align: left; }
+.install-label {
+  display: block; font-size: .75rem; letter-spacing: .14em; text-transform: uppercase;
+  color: var(--ink-2); margin-bottom: .4rem;
+}
+
+.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;
+}
+.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); }
+
+.hero-meta { margin-top: 2rem; color: var(--ink-2); font-size: .875rem; }
+
+/* ── Screenshots ──────────────────────────────────────────────────── */
+
+.shot { margin: 0; }
+.shot img {
+  display: block; width: 100%; height: auto;
+  border: 1px solid var(--line); border-radius: 6px;
+}
+.shot-hero {
+  width: min(100% - 2.5rem, 84rem);
+  margin: clamp(3rem, 7vw, 5rem) auto 0;
+}
+.shot-hero img { box-shadow: 0 2rem 5rem -1.5rem #00000099; }
+
+/* ── Bands ────────────────────────────────────────────────────────── */
+
+.band { padding-block: clamp(4rem, 9vw, 7rem); }
+.band-alt { background: #0D0725; border-block: 1px solid var(--line); }
+.band-close { padding-bottom: clamp(3rem, 6vw, 4.5rem); }
+
+.sec-title {
+  font-family: var(--disp); font-weight: 600;
+  font-size: clamp(1.7rem, 1.2rem + 1.9vw, 2.6rem);
+  line-height: 1.1; letter-spacing: -.02em; margin: 0 0 1rem;
+}
+.sec-lede { max-width: 44rem; color: var(--ink-2); margin: 0 0 2.5rem; }
+
+/* ── Features ─────────────────────────────────────────────────────── */
+
+.features {
+  list-style: none; margin: 0; padding: 0;
+  display: grid; gap: 1rem;
+  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+}
+.feature {
+  background: var(--panel);
+  border: 1px solid var(--line); border-left: 2px solid var(--accent);
+  border-radius: 5px;
+  padding: 1.4rem 1.4rem 1.6rem;
+}
+.feature-name {
+  display: inline-block; margin-bottom: .6rem;
+  color: var(--accent); font-weight: 700; font-size: .9375rem;
+  letter-spacing: .04em;
+}
+.feature-body { margin: 0; color: var(--ink-2); font-size: .9375rem; }
+.feature-body code {
+  color: var(--sky); background: #1B1140;
+  padding: .05em .3em; border-radius: 3px; font-size: .875em;
+}
+.feature-body em { font-style: italic; color: var(--ink); }
+
+/* ── Numbers ──────────────────────────────────────────────────────── */
+
+.numbers {
+  margin: 0; display: grid; gap: 2.5rem;
+  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+}
+.number { border-top: 2px solid var(--violet); padding-top: 1.25rem; }
+.number-value {
+  font-family: var(--disp); font-weight: 700;
+  font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.2rem);
+  line-height: 1; letter-spacing: -.03em; margin: 0 0 .5rem;
+}
+.number-label { margin: 0 0 .75rem; color: var(--ink); font-size: .9375rem; }
+.number-note { margin: 0; color: var(--ink-2); font-size: .875rem; }
+
+/* ── Split sections ───────────────────────────────────────────────── */
+
+.split { display: grid; gap: clamp(2rem, 5vw, 3.5rem); align-items: center; }
+@media (min-width: 56rem) {
+  .split { grid-template-columns: 22rem 1fr; }
+  .split-reverse .split-text { order: 2; }
+}
+
+.filters { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
+.filters code {
+  display: inline-block; background: var(--panel); border: 1px solid var(--line);
+  padding: .35rem .7rem; border-radius: 4px; font-size: .875rem; color: var(--sky);
+}
+
+.keys { list-style: none; margin: 0; padding: 0; display: grid; gap: .55rem; }
+.keys li { display: grid; grid-template-columns: 6.5rem 1fr; align-items: baseline; gap: .75rem; }
+.keys span { color: var(--ink-2); font-size: .9375rem; }
+
+kbd {
+  font-family: var(--mono); font-size: .8125rem; font-weight: 700;
+  background: var(--panel); border: 1px solid var(--line); border-bottom-width: 2px;
+  border-radius: 4px; padding: .2rem .5rem; color: var(--sky);
+  display: inline-block; text-align: center;
+}
+
+/* ── Requirements ─────────────────────────────────────────────────── */
+
+.reqs { display: grid; gap: 2.5rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); }
+.req h3 { font-family: var(--disp); font-size: 1.25rem; font-weight: 600; margin: 0 0 .75rem; }
+.req p { margin: 0 0 1rem; color: var(--ink-2); font-size: .9375rem; }
+.req code { background: var(--panel); padding: .05em .35em; border-radius: 3px; color: var(--ink); }
+.req-note { font-size: .8125rem; }
+
+.protocols { border-collapse: collapse; width: 100%; margin-bottom: 1rem; font-size: .875rem; }
+.protocols th, .protocols td { text-align: left; padding: .5rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
+.protocols th { font-weight: 700; color: var(--sky); white-space: nowrap; padding-right: 1.25rem; }
+.protocols td { color: var(--ink-2); }
+
+/* ── Install grid ─────────────────────────────────────────────────── */
+
+.install-grid {
+  display: grid; gap: 1rem; margin-bottom: 2rem;
+  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
+}
+
+/* ── Footer ───────────────────────────────────────────────────────── */
+
+.foot { border-top: 1px solid var(--line); padding-block: 2.5rem; }
+.foot-in { display: grid; gap: 1rem; }
+.foot-name {
+  margin: 0; font-weight: 700; letter-spacing: .1em;
+  background: linear-gradient(100deg, var(--pink), var(--cyan));
+  -webkit-background-clip: text; background-clip: text; color: transparent;
+  width: max-content;
+}
+.foot-links { display: flex; flex-wrap: wrap; gap: 1.25rem; font-size: .875rem; }
+.foot-fine { margin: 0; color: var(--ink-2); font-size: .8125rem; }
+
+/* ── The signature: ommp's status bar, tracking the page ──────────── */
+
+.statusbar {
+  position: fixed; inset: auto 0 0 0; z-index: 50;
+  height: var(--sb-h);
+  background: #0A051EF2;
+  backdrop-filter: blur(8px);
+  border-top: 1px solid var(--line);
+  font-size: .8125rem;
+}
+.sb-fill {
+  height: 2px; width: 0%;
+  background: var(--violet);
+  transition: width .1s linear;
+}
+.sb-row {
+  height: calc(var(--sb-h) - 2px);
+  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
+  gap: 1rem;
+  width: min(100% - 2.5rem, var(--wrap)); margin-inline: auto;
+}
+.sb-state { color: var(--playing); font-weight: 700; white-space: nowrap; }
+.sb-track {
+  color: var(--sky); text-align: center;
+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.sb-time { color: var(--ink-2); text-align: right; white-space: nowrap; }
+
+@media (max-width: 34rem) {
+  .sb-row { grid-template-columns: auto 1fr; }
+  .sb-state { display: none; }
+  .sb-time { font-size: .75rem; }
+}
diff --git a/site/static/fonts/bricolage-grotesque-var.woff2 b/site/static/fonts/bricolage-grotesque-var.woff2
Binary files differ.
diff --git a/site/static/fonts/jetbrains-mono-400.woff2 b/site/static/fonts/jetbrains-mono-400.woff2
Binary files differ.
diff --git a/site/static/fonts/jetbrains-mono-700.woff2 b/site/static/fonts/jetbrains-mono-700.woff2
Binary files differ.
diff --git a/site/static/img/01-main.png b/site/static/img/01-main.png
Binary files differ.
diff --git a/site/static/img/02-help.png b/site/static/img/02-help.png
Binary files differ.
diff --git a/site/static/img/03-search.png b/site/static/img/03-search.png
Binary files differ.
diff --git a/site/static/img/logo.png b/site/static/img/logo.png
Binary files differ.
diff --git a/site/static/js/status.js b/site/static/js/status.js
@@ -0,0 +1,61 @@
+// The bar pinned to the bottom of the page is ommp's own status bar. It reads
+// the page the way the player reads a track: how far in you are, and what is
+// playing right now. Nothing here is required to read the page — if this file
+// never loads, the bar simply sits at 0:00.
+(function () {
+  "use strict";
+
+  var fill = document.querySelector(".sb-fill");
+  var track = document.querySelector(".sb-track");
+  var elapsed = document.querySelector(".sb-elapsed");
+  if (!fill || !track || !elapsed) return;
+
+  var TOTAL = 237; // 3:57, the length of the track in the screenshot
+  var sections = Array.prototype.slice.call(document.querySelectorAll("[data-section]"));
+
+  function clock(seconds) {
+    var s = Math.round(seconds);
+    return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
+  }
+
+  function update() {
+    var scrollable = document.documentElement.scrollHeight - window.innerHeight;
+    var progress = scrollable > 0 ? Math.min(1, Math.max(0, window.scrollY / scrollable)) : 0;
+
+    fill.style.width = (progress * 100).toFixed(2) + "%";
+    elapsed.textContent = clock(progress * TOTAL);
+
+    // Whichever labelled section has most recently crossed the middle of the
+    // viewport is the one you are reading.
+    var middle = window.innerHeight / 2;
+    var current = "ommp";
+    for (var i = 0; i < sections.length; i++) {
+      if (sections[i].getBoundingClientRect().top <= middle) {
+        current = sections[i].getAttribute("data-section");
+      }
+    }
+    if (track.textContent !== current) track.textContent = current;
+  }
+
+  var queued = false;
+  function onScroll() {
+    if (queued) return;
+    queued = true;
+    requestAnimationFrame(function () { queued = false; update(); });
+  }
+
+  addEventListener("scroll", onScroll, { passive: true });
+  addEventListener("resize", onScroll);
+  update();
+
+  // Click a command to copy it — the page is mostly commands.
+  document.querySelectorAll("[data-copy]").forEach(function (el) {
+    el.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);
+      });
+    });
+  });
+})();
diff --git a/site/templates/landing.html b/site/templates/landing.html
@@ -0,0 +1,211 @@
+{%- set x = page.extra -%}
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ site.title }} — {{ site.description }}</title>
+<meta name="description" content="{{ page.description }}">
+<meta name="color-scheme" content="dark">
+<meta name="theme-color" content="#0A051E">
+
+<meta property="og:type" content="website">
+<meta property="og:title" content="{{ site.title }} — Oh My Music Player">
+<meta property="og:description" content="{{ page.description }}">
+<meta property="og:url" content="{{ page.permalink }}">
+<meta property="og:image" content="{{ site.origin }}{{ asset('img/01-main.png') }}">
+<meta name="twitter:card" content="summary_large_image">
+
+<link rel="icon" href="{{ asset('img/logo.png') }}">
+<link rel="preload" as="font" type="font/woff2" href="{{ asset('fonts/jetbrains-mono-400.woff2') }}" crossorigin>
+<link rel="preload" as="font" type="font/woff2" href="{{ asset('fonts/bricolage-grotesque-var.woff2') }}" crossorigin>
+<link rel="stylesheet" href="{{ asset('css/main.css') }}">
+</head>
+<body>
+
+<a class="skip" href="#what">Skip to content</a>
+
+<!-- ── Hero ──────────────────────────────────────────────────────────── -->
+<header class="hero">
+  <div class="wrap hero-in">
+    <img class="hero-logo" src="{{ asset('img/logo.png') }}" alt="ommp" width="723" height="421">
+
+    <p class="kicker">{{ x.kicker }}</p>
+    <h1 class="display">
+      {{ x.headline }}<br><em>{{ x.headline2 }}</em>
+    </h1>
+    <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>
+    </div>
+
+    <p class="hero-meta">
+      Linux · Apache&nbsp;2.0 ·
+      <a href="https://github.com/sqzer-x/ommp">Source</a> ·
+      <a href="https://crates.io/crates/ommp">crates.io</a> ·
+      <a href="https://aur.archlinux.org/packages/ommp">AUR</a>
+    </p>
+  </div>
+
+  <figure class="shot shot-hero">
+    <img src="{{ asset('img/01-main.png') }}" alt="ommp playing a track: library on the left, queue in the middle, album art and track details on the right"
+         width="2400" height="1350" fetchpriority="high">
+  </figure>
+</header>
+
+<!-- ── Features, keyed to the tabs ───────────────────────────────────── -->
+<section id="what" class="band" data-section="Seven ways in">
+  <div class="wrap">
+    <h2 class="sec-title">Seven ways into the same library</h2>
+    <p class="sec-lede">The tab strip across the top is the whole navigation model. Each one is a different question about the same files.</p>
+
+    <ul class="features">
+      {%- for f in x.features %}
+      <li class="feature" style="--accent: {{ f.color }}">
+        <span class="feature-name">{{ f.name }}</span>
+        <p class="feature-body">{{ f.body | safe }}</p>
+      </li>
+      {%- endfor %}
+    </ul>
+  </div>
+</section>
+
+<!-- ── Speed ─────────────────────────────────────────────────────────── -->
+<section class="band band-alt" data-section="Speed">
+  <div class="wrap">
+    <h2 class="sec-title">Fast enough to forget about</h2>
+    <p class="sec-lede">Measured on this machine, not estimated. A library that takes a moment to open is a library you stop opening.</p>
+
+    <dl class="numbers">
+      {%- for n in x.numbers %}
+      <div class="number">
+        <dt class="number-value">{{ n.value }}</dt>
+        <dd class="number-label">{{ n.label }}</dd>
+        <dd class="number-note">{{ n.note }}</dd>
+      </div>
+      {%- endfor %}
+    </dl>
+  </div>
+</section>
+
+<!-- ── Search ────────────────────────────────────────────────────────── -->
+<section class="band" data-section="Search">
+  <div class="wrap split">
+    <div class="split-text">
+      <h2 class="sec-title">Find it while you type</h2>
+      <p class="sec-lede">Results narrow on every keystroke. Narrow further with a field, or by format.</p>
+      <ul class="filters">
+        <li><code>artist:yorushika</code></li>
+        <li><code>album:elma</code></li>
+        <li><code>genre:rock</code></li>
+        <li><code>*.flac</code></li>
+      </ul>
+    </div>
+    <figure class="shot">
+      <img src="{{ asset('img/03-search.png') }}" alt="The search overlay filtering a library down to fifty of fifty-two tracks"
+           width="2400" height="1350" loading="lazy">
+    </figure>
+  </div>
+</section>
+
+<!-- ── Keys ──────────────────────────────────────────────────────────── -->
+<section class="band band-alt" data-section="Keys">
+  <div class="wrap split split-reverse">
+    <div class="split-text">
+      <h2 class="sec-title">Keys where you expect them</h2>
+      <p class="sec-lede">Vim motions for the lists, media keys for the music. The mouse works too — click a tab, double-click a track, <kbd>Ctrl</kbd>-drag a border to resize.</p>
+      <ul class="keys">
+        {%- for k in x.keys %}
+        <li><kbd>{{ k.key }}</kbd><span>{{ k.desc }}</span></li>
+        {%- endfor %}
+      </ul>
+    </div>
+    <figure class="shot">
+      <img src="{{ asset('img/02-help.png') }}" alt="The keybinding overlay, two columns covering playback and navigation"
+           width="2400" height="1350" loading="lazy">
+    </figure>
+  </div>
+</section>
+
+<!-- ── Requirements ──────────────────────────────────────────────────── -->
+<section class="band" data-section="Before you start">
+  <div class="wrap">
+    <h2 class="sec-title">Two things to check first</h2>
+
+    <div class="reqs">
+      <div class="req">
+        <h3>A Nerd Font</h3>
+        <p>The interface is built from <a href="https://www.nerdfonts.com/">Nerd Font</a> icons. Without one they render as empty boxes. Install <code>JetBrainsMono Nerd Font</code> or similar and set it as your terminal font.</p>
+      </div>
+      <div class="req">
+        <h3>A graphics protocol, for album art</h3>
+        <p>Cover art is read out of the audio file itself and drawn as a real image where the terminal supports it. ommp detects which protocol is available at startup — there is nothing to configure.</p>
+        <table class="protocols">
+          <tbody>
+          {%- for p in x.protocols %}
+            <tr><th scope="row">{{ p.name }}</th><td>{{ p.terms }}</td></tr>
+          {%- endfor %}
+          </tbody>
+        </table>
+        <p class="req-note">Anything else falls back to block characters, which still shows the artwork — just coarser. GPU acceleration is unrelated: Alacritty is accelerated and supports none of these; xterm is not and supports Sixel.</p>
+      </div>
+    </div>
+  </div>
+</section>
+
+<!-- ── Install ───────────────────────────────────────────────────────── -->
+<section class="band band-close" data-section="Install">
+  <div class="wrap">
+    <h2 class="sec-title">Install</h2>
+    <div class="install-grid">
+      <div class="install-card">
+        <span class="install-label">Arch Linux</span>
+        <code class="cmd" data-copy>yay -S ommp</code>
+      </div>
+      <div class="install-card">
+        <span class="install-label">With Rust installed</span>
+        <code class="cmd" data-copy>cargo install ommp</code>
+      </div>
+      <div class="install-card">
+        <span class="install-label">From source</span>
+        <code class="cmd" data-copy>git clone https://github.com/sqzer-x/ommp</code>
+      </div>
+    </div>
+    <p class="sec-lede">Then put some music in <code>~/Music</code> and run <code>ommp</code>.</p>
+  </div>
+</section>
+
+<footer class="foot">
+  <div class="wrap foot-in">
+    <p class="foot-name">ommp</p>
+    <nav class="foot-links">
+      <a href="https://github.com/sqzer-x/ommp">GitHub</a>
+      <a href="https://crates.io/crates/ommp">crates.io</a>
+      <a href="https://aur.archlinux.org/packages/ommp">AUR</a>
+      <a href="https://github.com/sqzer-x/ommp/blob/main/LICENSE">Apache&nbsp;2.0</a>
+    </nav>
+    <p class="foot-fine">Built with <a href="https://github.com/sqzer-x/sqzass">sqzass</a>. Copyright 2026 sqzer (hyukjin an).</p>
+  </div>
+</footer>
+
+<!-- ── The app's own status bar, tracking the page ───────────────────── -->
+<div class="statusbar" aria-hidden="true">
+  <div class="sb-fill"></div>
+  <div class="sb-row">
+    <span class="sb-state">&#9654; Playing</span>
+    <span class="sb-track">ommp</span>
+    <span class="sb-time"><span class="sb-elapsed">0:00</span> / 3:57</span>
+  </div>
+</div>
+
+<script src="{{ asset('js/status.js') }}" defer></script>
+</body>
+</html>
diff --git a/site/templates/page.html b/site/templates/page.html
@@ -0,0 +1,19 @@
+{#- Fallback template. sqzass falls back to page.html for anything without an
+    explicit `template` in its front matter, and errors out if it is missing.
+    The site is one page, which uses landing.html, so this is a safety net. -#}
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ page.title }} — {{ site.title }}</title>
+<meta name="description" content="{{ page.description }}">
+<link rel="stylesheet" href="{{ asset('css/main.css') }}">
+</head>
+<body>
+<main class="wrap prose">
+  <h1>{{ page.title }}</h1>
+  {{ page.content | safe }}
+</main>
+</body>
+</html>
diff --git a/site/tools/prep-state.py b/site/tools/prep-state.py
@@ -0,0 +1,9 @@
+"""Shoot-only player settings; the caller restores the originals afterwards."""
+import json, sys
+p = sys.argv[1]
+d = json.load(open(p))
+d["volume"] = 0.72           # a realistic meter; silence comes from muting the stream
+d["pane_widths"] = [20, 50, 30]  # wider right column: track info stops wrapping
+d["right_split"] = 46        # a little more room for the info fields
+d["info_view"] = "AlbumArt"
+json.dump(d, open(p, "w"), indent=2)
diff --git a/site/tools/shoot.sh b/site/tools/shoot.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Capture ommp screenshots for the landing page.
+#
+# Runs the real binary in a real terminal: album art only renders as an image
+# under a graphics protocol, and Ghostty is the only installed terminal that
+# speaks one. Anything else would show the block-character fallback.
+#
+# The window is fullscreen for about fifteen seconds and then closes. Your
+# player settings are backed up and restored — the app rewrites them on exit,
+# and this script deliberately changes a few for the shot.
+set -euo pipefail
+
+export XDG_RUNTIME_DIR=/run/user/1000
+export WAYLAND_DISPLAY=wayland-1
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+OUT=${OUT:-/tmp/ommp-shots}
+FONT_SIZE=${FONT_SIZE:-20}
+OMMP=${OMMP:-/home/himesama/Documents/ommp/target/release/ommp}
+MONITOR=${MONITOR:-DP-1}
+STATE="$HOME/.config/ommp/state.json"
+
+mkdir -p "$OUT"
+BAK="$OUT/state.json.bak"
+[ -f "$STATE" ] && [ ! -f "$BAK" ] && cp -a "$STATE" "$BAK"
+
+cleanup() {
+  pkill -x ommp 2>/dev/null || true
+  sleep 0.4
+  [ -f "$BAK" ] && cp -a "$BAK" "$STATE"
+}
+trap cleanup EXIT
+
+python3 "$HERE/prep-state.py" "$STATE"
+
+# --background matches the logo's #0A051E so padding is not a different black.
+# --palette pins the ANSI colours the app draws selections and the progress bar
+# with, so the shot does not depend on whatever theme happens to be configured.
+ghostty \
+  --font-family="JetBrainsMono Nerd Font" \
+  --font-size="$FONT_SIZE" \
+  --background=0A051E \
+  --foreground=E8E6F0 \
+  --palette=6=#00ffff --palette=14=#00ffff \
+  --palette=5=#c850ff --palette=13=#c850ff \
+  --fullscreen=true \
+  --window-decoration=none \
+  --window-padding-x=16 --window-padding-y=12 \
+  --confirm-close-surface=false \
+  -e "$OMMP" &
+
+# The splash runs a fixed two seconds; wait it out plus a beat for the first draw.
+sleep 5
+
+shoot() { grim -o "$MONITOR" "$OUT/$1.png" && echo "  -> $1.png"; }
+
+# Mute the player's audio stream at the server, not in the app: the volume
+# meter is part of the shot, so it has to read a real level while staying
+# silent. The stream exists from startup, before anything plays.
+SINK_INPUT=""
+for _ in $(seq 20); do
+  SINK_INPUT=$(pactl -f json list sink-inputs 2>/dev/null \
+    | python3 -c 'import json,sys
+try:
+    for si in json.load(sys.stdin):
+        if si["properties"].get("application.process.binary") == "ommp":
+            print(si["index"]); break
+except Exception:
+    pass')
+  [ -n "$SINK_INPUT" ] && break
+  sleep 0.25
+done
+if [ -n "$SINK_INPUT" ]; then
+  pactl set-sink-input-mute "$SINK_INPUT" 1
+  echo "  muted sink-input $SINK_INPUT"
+else
+  echo "  WARNING: could not find the player audio stream; it will be audible" >&2
+fi
+
+# Start playback so the status bar reads Playing with a partly filled progress
+# bar, rather than Stopped at 0:00.
+wtype " "
+sleep 9
+
+shoot 01-main
+
+wtype -M ctrl -k h -m ctrl; sleep 0.8; shoot 02-help;   wtype -k Escape; sleep 0.5
+wtype -M ctrl -k s -m ctrl; sleep 0.8; wtype -d 40 "yorushika"; sleep 1.0
+shoot 03-search; wtype -k Escape; sleep 0.5
+
+echo "done: $OUT"
diff --git a/src/ui/widgets/help_modal.rs b/src/ui/widgets/help_modal.rs
@@ -78,7 +78,7 @@ fn render_column(bindings: &[(&str, &str)], theme: &Theme) -> Vec<Line<'static>>
             }
             Line::from(vec![
                 Span::styled(
-                    format!("  {:16}", key),
+                    format!("  {:18}", key),
                     Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
                 ),
                 Span::styled(desc.to_string(), Style::default().fg(theme.fg)),