Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions site/assets/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -587,12 +587,24 @@ nav[aria-label="breadcrumb"],
-webkit-text-fill-color: currentColor;
letter-spacing: -0.02em;
}
.plugii-hero h1::before {
/* Every line of the typed headline (layouts/_shortcodes/hero-typed.html) in
one grid cell: the invisible ones hold the tallest line's height. Each gets
the prompt and the cursor so they wrap the same way. */
.plugii-typed {
display: grid;
}
.plugii-typed > span {
grid-area: 1 / 1;
}
.plugii-typed > .sizer {
visibility: hidden;
}
.plugii-typed > span::before {
content: "> ";
color: var(--p-accent);
}
/* The block cursor. */
.plugii-hero h1::after {
/* The block cursor: solid while it types or erases, blinking otherwise. */
.plugii-typed > span::after {
content: "";
display: inline-block;
width: 0.55em;
Expand All @@ -602,16 +614,51 @@ nav[aria-label="breadcrumb"],
background: var(--p-accent);
animation: plugii-blink 1.1s steps(1, end) infinite;
}
.plugii-typed.busy > span::after {
animation: none;
}
@keyframes plugii-blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.plugii-hero h1::after {
.plugii-typed > span::after {
animation: none;
}
}
/* The pause toggle hangs in the headline's bottom margin, out of the flow,
so it takes no room and never shifts anything. 24px: the minimum target. */
.plugii-typed-box {
position: relative;
}
.plugii-typed-pause {
position: absolute;
top: 100%;
left: 0;
height: 24px;
padding: 0 6px;
font-family: var(--p-mono);
font-size: 0.75rem;
line-height: 1;
color: var(--p-faint);
background: none;
border: 0;
cursor: pointer;
}
.plugii-typed-pause span {
display: inline-block;
width: 1ch;
color: var(--p-accent);
}
.plugii-typed-pause:hover,
.plugii-typed-pause:focus-visible {
color: var(--p-accent);
}
.plugii-typed-pause:focus-visible {
outline: 2px solid var(--p-accent);
outline-offset: 1px;
}
.plugii-hero p {
color: var(--p-muted) !important;
}
Expand Down
12 changes: 9 additions & 3 deletions site/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
title: plugii
description: A live USB port monitor for Linux. Which socket, what link speed, USB-C / PD power, a health verdict, and a vim-key TUI.
layout: hextra-home
# The headline, typed round and round (layouts/_shortcodes/hero-typed.html):
# each line held for `hold` ms. `tagline` is what screen readers, reduced
# motion and no-JS get, and where a return visit picks up.
hero:
- { text: "plugii: a live USB port monitor for Linux", hold: 4500 }
- { text: "Know what every USB port is really doing", hold: 5000, tagline: true }
- { text: "10G capable, linked at 5G. It's the cable.", hold: 2500 }
- { text: "USB-C: the C is for Could be anything.", hold: 2500 }
---

{{< hextra/hero-badge link="https://github.com/rdlu/plugii" >}}
Expand All @@ -12,9 +20,7 @@ layout: hextra-home

<div class="plugii-hero">
<div class="hx:mt-6 hx:mb-6">
{{< hextra/hero-headline >}}
Know what every USB port&nbsp;<br class="hx:sm:block hx:hidden" />is really doing
{{< /hextra/hero-headline >}}
{{< hero-typed >}}
</div>

<div class="hx:mb-10">
Expand Down
99 changes: 99 additions & 0 deletions site/layouts/_shortcodes/hero-typed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{{- /*
The home page's headline, typed like a prompt, round and round: each line
of the page's `hero` front matter typed, held for its `hold` ms, backspaced,
then the next. The line marked `tagline: true` is the h1's aria-label (screen
readers never hear the typing), what shows without JavaScript or with
reduced motion, and where the rotation picks up on a return visit in the
same tab: the first line plays once per session.

Every line sits invisibly in the h1's grid cell, so the box is the tallest
one's height at any width and the typing never moves the page.

It stops while the mouse is over the line, the pause toggle has keyboard
focus, the tab is hidden or the hero is scrolled away, and for good with the
toggle (WCAG 2.2.2). A pause keeps its place in the line.
*/ -}}
{{- $lines := .Page.Params.hero -}}
{{- $tagline := index $lines 0 -}}
{{- range $lines }}{{ if .tagline }}{{ $tagline = . }}{{ end }}{{ end -}}
<div class="plugii-typed-box">
<h1 class="plugii-typed not-prose hx:text-4xl hx:font-bold hx:leading-none hx:tracking-tighter hx:md:text-5xl hx:py-2" aria-label="{{ $tagline.text }}">
{{- range $lines }}<span class="sizer" aria-hidden="true">{{ .text }}</span>{{ end -}}
<span class="typed" aria-hidden="true">{{ $tagline.text }}</span>
</h1>
<button type="button" class="plugii-typed-pause" hidden aria-label="Pause the title animation"><span aria-hidden="true">‖</span> pause</button>
</div>
<script>
(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const PHRASES = {{ $lines | jsonify | safeJS }};
const T = Math.max(0, PHRASES.findIndex((l) => l.tagline));
const box = document.currentScript.previousElementSibling;
const h1 = box.querySelector("h1");
const out = box.querySelector(".typed");
const btn = box.querySelector("button");
btn.hidden = false;

let fresh = true;
try { fresh = !sessionStorage.getItem("plugii-hero"); sessionStorage.setItem("plugii-hero", "1"); } catch {}

// The phrase on screen, how much of it, typing/holding/erasing, and the ms
// until the next key.
let p = fresh ? 0 : T;
let len = fresh ? 0 : PHRASES[T].text.length;
let phase = fresh ? "type" : "hold";
let wait = fresh ? 500 : PHRASES[T].hold;
let timer = null;
let paused = false, held = false, hovered = false, hidden = document.hidden, away = false;
const key = () => 35 + Math.random() * 35; // a human's 35-70 ms a key
const show = () => { out.textContent = PHRASES[p].text.slice(0, len); };
show();

function act() {
const { text, hold } = PHRASES[p];
if (phase === "type") {
len++;
if (len < text.length) wait = key();
else { phase = "hold"; wait = hold; }
} else {
phase = "erase";
len--;
wait = 18; // a quick backspace
if (len === 0) { p = (p + 1) % PHRASES.length; phase = "type"; wait = 350; }
}
show();
}

// Start or stop the clock to match the flags. The cursor blinks whenever
// the text isn't moving.
function sync() {
const run = !paused && !held && !hovered && !hidden && !away;
h1.classList.toggle("busy", run && phase !== "hold");
if (run && timer === null) {
timer = setTimeout(() => { timer = null; act(); sync(); }, wait);
} else if (!run && timer !== null) {
clearTimeout(timer);
timer = null;
}
}

// Resuming also lifts the focus hold, or it would only move again once
// focus left the button.
btn.addEventListener("click", () => {
paused = !paused;
if (!paused) held = false;
btn.setAttribute("aria-label", paused ? "Resume the title animation" : "Pause the title animation");
btn.innerHTML = paused ? '<span aria-hidden="true">▶</span> resume' : '<span aria-hidden="true">‖</span> pause';
sync();
});
btn.addEventListener("focus", () => { held = btn.matches(":focus-visible"); sync(); });
btn.addEventListener("blur", () => { held = false; sync(); });
// Mouse only, so a tap doesn't leave it stuck.
const hover = (on) => (e) => { if (e.pointerType === "mouse") { hovered = on; sync(); } };
h1.addEventListener("pointerenter", hover(true));
h1.addEventListener("pointerleave", hover(false));
document.addEventListener("visibilitychange", () => { hidden = document.hidden; sync(); });
new IntersectionObserver(([e]) => { away = !e.isIntersecting; sync(); }).observe(h1);
sync();
})();
</script>