feat: typewriter-эффект в логотипе dedinit() — темы блога в скобках

- layouts/partials/extend_footer.html: инлайн-скрипт (печать/пауза/стирание/мигание, prefers-reduced-motion, слова из [params.typewriter])
- assets/css/extended/typewriter.css: блочный мигающий курсор
- hugo.toml: [params.typewriter] words
- openspec/changes/header-typewriter: proposal/design/specs/tasks

Без зависимостей (Alpine не нужен), тема не модифицируется.
This commit is contained in:
2026-09-19 17:42:59 +00:00
parent a0b3749a1b
commit 2af0530f8a
7 changed files with 338 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
{{- /* dedinit() typewriter: animated topics inside logo parens.
Pure JS + CSS, no Alpine. Progressive enhancement — no-JS keeps
the plain "dedinit()" logo. Words configurable via hugo.toml:
[params.typewriter] words = ["linux", "llm", "politics"] */ -}}
{{- $tw_words := site.Params.typewriter.words | default (slice "linux" "llm" "politics" "homelab" "nix") -}}
<script>
(() => {
const LOGO_SEL = 'header .logo a, .logo a';
const queue = [() => {
const a = document.querySelector(LOGO_SEL);
if (!a || a.dataset.twDone) return;
a.dataset.twDone = '1';
const text = a.textContent.trim();
const open = text.lastIndexOf('(');
const close = text.lastIndexOf(')');
if (open < 0 || close < open) return;
const words = {{ $tw_words | jsonify | safeJS }};
const box = document.createElement('span');
box.id = 'dedinit-typewriter';
box.className = 'tw';
box.textContent = '';
box.appendChild(Object.assign(document.createElement('span'), { className: 'tw-cursor' }));
// rebuild: <prefix>(<typewriter>)<suffix>
a.textContent = '';
a.appendChild(document.createTextNode(text.slice(0, open + 1)));
a.appendChild(box);
a.appendChild(document.createTextNode(text.slice(close)));
// reduced motion: static first word, no animation
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
box.firstChild.before(document.createTextNode(words[0] || ''));
box.querySelector('.tw-cursor').style.display = 'none';
return;
}
const TYPE = 90, ERASE = 50, HOLD = 1400, GAP = 400;
let w = 0, i = 0, dir = 1;
box.querySelector('.tw-cursor').style.display = 'inline-block';
const tick = () => {
const word = words[w];
// keep cursor as the last child; typed text goes before it
box.replaceChildren(document.createTextNode(word.slice(0, i)), box.querySelector('.tw-cursor'));
let delay;
if (dir === 1) {
if (i < word.length) { i++; delay = TYPE; }
else { dir = -1; delay = HOLD; }
} else {
if (i > 0) { i--; delay = ERASE; }
else { dir = 1; w = (w + 1) % words.length; delay = GAP; }
}
setTimeout(tick, delay);
};
tick();
}];
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => queue.shift()());
else queue.shift()();
})();
</script>