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
+30
View File
@@ -0,0 +1,30 @@
/* dedinit() typewriter — animated blog topics inside the logo parens.
Progressive enhancement: pure CSS cursor blink, JS only drives the text. */
#dedinit-typewriter {
display: inline-block;
white-space: nowrap; /* never wrap the typed word */
min-width: 0.5ch; /* keeps parens from collapsing when empty */
text-align: left;
}
#dedinit-typewriter .tw-cursor {
display: inline-block;
width: 0.55ch;
height: 1.05em;
margin-inline-start: 0.15ch;
background: currentColor; /* follows light/dark text color */
vertical-align: -0.12em;
animation: tw-blink 1s steps(1) infinite;
}
@keyframes tw-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
#dedinit-typewriter .tw-cursor {
animation: none; /* static cursor, no blink */
opacity: 0.6;
}
}
+4
View File
@@ -37,6 +37,10 @@ draft = false
# Полный текст постов в RSS-ленте (content:encoded) # Полный текст постов в RSS-ленте (content:encoded)
ShowFullTextinRSS = true ShowFullTextinRSS = true
# Типографика логотипа dedinit(): темы, печатаемые в скобках (typewriter)
[params.typewriter]
words = ["linux", "llm", "politics", "homelab", "nix", "devops"]
# Настройки PaperMod # Настройки PaperMod
defaultTheme = "auto" defaultTheme = "auto"
disableThemeToggle = false disableThemeToggle = false
+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>
@@ -0,0 +1,81 @@
# Design: Анимированный логотип-печатная машинка (header typewriter)
## Context
- Тема: rDedInit (форк PaperMod), Hugo Extended 0.165.x.
- Логотип в шапке — партиал `themes/rDedInit/layouts/partials/header.html`,
ссылка `<a class="logo">` содержит текст `{{ $label_text }}` = `dedinit()`
(`site.Params.label.text`).
- Хуки темы: `layouts/partials/extend_head.html` (в `<head>`) и
`layouts/partials/extend_footer.html` (перед `</body>`). Локальные layouts/
переопределяют тему. В `footer.html` темы есть
`{{- partial "extend_footer.html" . }}` — подтверждено grep.
- CSS: тема собирает всё из `assets/css/extended/*.css` в один
`extended.css` (в `head.html`: `resources.Match "css/extended/*.css"`).
## Files
1. `layouts/partials/extend_footer.html` (НОВЫЙ) — подключает inline `<script>`.
Hugo сам добавит партиал в футер всех страниц (переопределение темы).
Скрипт маленький (~1 КБ), inline — без отдельного assets/js (меньше
запросов; для трёхстрочного эффекта отдельный бандл избыточен).
Слова передаём из Hugo: `data-words="{{ delimit (site.Params.typewriter.words | default (slice "linux" "llm" "politics" "homelab" "nix")) "," }}"`.
2. `assets/css/extended/typewriter.css` (НОВЫЙ) — курсор и вставка:
```css
#dedinit-typewriter { display:inline-block; }
#dedinit-typewriter .tw-cursor { ... блочный квадратик, animation: tw-blink 1s steps(1) infinite ... }
@keyframes tw-blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { #dedinit-typewriter .tw-cursor { animation: none; } }
```
Курсор — всегда видимый блок `currentColor`, чтобы не зависеть от схемы
(light/dark).
3. `hugo.toml` (ОПЦИОНАЛЬНО) — `[params.typewriter] words = [...]`. Если не
задан — скрипт использует фолбэк. Не обязателен для эффекта.
## JS logic (inline in extend_footer.html)
```js
(() => {
const a = document.querySelector('header a.logo');
if (!a || a.dataset.twDone) return;
const words = (a.dataset.words || 'linux,llm,politics,homelab,nix').split(',');
// вставить <span id="dedinit-typewriter"><span class="tw-cursor"></span></span>
// перед ')' в тексте логотипа; первый клик -- печать
// тайминги: type 90ms/char, hold 1400ms, erase 50ms/char, gap 400ms
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { первый word статично }
})();
```
- Символы печатаются через `textContent` — безопасно (нет HTML-инъекции из
слов; слова приходят из конфига автора).
- Слово не должно разрываться: `white-space: nowrap` на тизере.
- Курсор мигает на пустом месте между циклами (это и есть «моргает на месте»).
## Apply
```bash
# 1. создать файлы (выше), 2. собрать:
make build # hugo --minify
```
## Verify
```bash
grep -n "dedinit-typewriter" public/index.html # скрипт + span присутствуют
grep -n "tw-cursor" public/index.html # CSS-курсор
grep -n "prefers-reduced-motion" public/**.html # a11y-заглушка
# в браузере: логотип печатает/стирает слова по циклу; курсор мигает;
# с prefers-reduced-motion — первое слово статично
```
## Rollback
```bash
git rm layouts/partials/extend_footer.html assets/css/extended/typewriter.css
# (и убрать [params.typewriter] из hugo.toml, если добавлялся)
make build
```
Публикация/деплой — по согласованию с пользователем (`make deploy`).
@@ -0,0 +1,42 @@
# Proposal: Анимированный логотип-печатная машинка для dedinit()
## Why
Логотип в шапке сайта `dedinit()` — статический текст. Хочется «оживить»
брендинг: внутри круглых скобок в логотипе должен появляться блочный курсор и
«печатать» по одному символу темы/теги блога (linux, llm, politics, homelab...),
замирать на секунду, стирать слово посимвольно, мигать на пустом месте и снова
печатать следующее слово. Это декоративный эффект печатной машинки
(typewriter), который обыгрывает название блога как вызов функции
`dedinit(...)` — «инициализация» блога.
## What Changes
- Новый крошечный JS-инлайн (~1 КБ, без зависимостей, без Alpine.js):
- Находит тег `<a class="logo">` на странице, внутри текста `dedinit()`
выделяет скобки, вставляет `<span id="dedinit-typewriter">` между `( )`.
- Цикл: печать слова по 1 символу (быстро), пауза (~1.4 с), стирание по
1 символу, пауза с курсором (~0.4 с), следующее слово.
- Слова берутся из `data-words` атрибута (передаются из Hugo конфига,
`site.Params.typewriter.words`), фолбэк на массив по умолчанию.
- Уважает `prefers-reduced-motion`: при включённом — вместо анимации
показывается первое слово статически.
- Если JS выключен — логотип остаётся обычным `dedinit()` (прогрессивное
улучшение).
- Новый CSS в `assets/css/extended/typewriter.css` (тема PaperMod
автоматически подхватывает всё из `assets/css/extended/`):
- Блочный курсор в виде квадратика с миганием (CSS-анимация, не JS).
- Стили для слова-тизера в скобках.
- Хук JS через переопределение партиала `layouts/partials/extend_footer.html`
(Hugo: локальные layouts переопределяют тему; там подключаем `<script>`).
## Impact
- Затронутые файлы:
- `layouts/partials/extend_footer.html` (новый, переопределяет тему)
- `assets/css/extended/typewriter.css` (новый)
- `hugo.toml` — необязательно: `[params.typewriter] words = [...]` (опция)
- Тема `themes/rDedInit` НЕ модифицируется (правки не затрутся апдейтом темы).
- Контент постов, RSS, разметка списков — не меняются.
- Откат (rollback): удалить 2 новых файла (и, если добавляли, параметр
`typewriter` в hugo.toml), пересобрать — сайт вернётся к статическому логотипу.
@@ -0,0 +1,86 @@
# Site Header Typewriter Capability
## Purpose
Логотип `dedinit()` в шапке сайта анимирован эффектом печатной машинки:
внутри скобок блочный курсор печатает темы блога, замирает, стирает их и
переходит к следующей — обыгрывая название как вызов функции `dedinit(...)`.
## ADDED Requirements
### Requirement: Анимация «печатная машинка» в логотипе
Система SHALL анимировать текст в скобках логотипа `dedinit()` на всех
страницах сайта: блочный курсор печатает слово по одному символу, удерживает
его ~1 секунду, стирает по одному символу, мигает на пустом месте и печатает
следующее слово — бесконечным циклом по списку тем блога.
#### Scenario: Логотип печатает слова по циклу
- **GIVEN** открыта любая страница сайта (`/` или внутренняя), JS включён
- **WHEN** страница загружена
- **THEN** в `dedinit()` между скобками присутствует контейнер
`#dedinit-typewriter` с блочным мигающим курсором, и текст посимвольно
печатается из списка тем (например, `linux` → `llm` → `politics` → ...)
#### Scenario: Цикл без остановки
- **GIVEN** анимация запущена
- **WHEN** прошло более 3 слов печати/стирания
- **THEN** анимация продолжает цикл (после последнего слова переходит к
первому), не останавливаясь и не накапливая ошибок
### Requirement: Бесшовный вывод (прогрессивное улучшение)
Система SHALL оставаться декоративным улучшением: при выключенном JS логотип
остаётся обычным текстом `dedinit()`; при его включении — анимация
накладывается, не ломая вёрстку и навигацию шапки.
#### Scenario: JS отключён — логотип статичен
- **GIVEN** браузер с отключённым JavaScript
- **WHEN** страница загружена
- **THEN** в шапке отображается обычный текст `dedinit()` без скобочного
контейнера и без ошибок
### Requirement: Доступность (prefers-reduced-motion)
Система SHALL уважать системную настройку `prefers-reduced-motion: reduce`:
при ней показывать первое слово списка статически, без анимации и мигания.
#### Scenario: Уменьшение движения
- **GIVEN** у пользователя включено `prefers-reduced-motion: reduce`
- **WHEN** страница загружена
- **THEN** внутри скобок логотипа отображается первое слово списка статично,
курсор не мигает (CSS `animation: none`), JS-цикл не запускается
### Requirement: Минимальный размер и отсутствие зависимостей
Система SHALL реализовывать эффект без внешних зависимостей (без Alpine.js,
без jQuery): инлайн-скрипт и CSS суммарно не более ~2 КБ.
#### Scenario: Нет внешних библиотек
- **GIVEN** собранный сайт
- **WHEN** просматривается HTML страницы
- **THEN** в `<head>`/футере не подключено никаких сторонних JS-библиотек для
эффекта (только собственный инлайн-скрипт и CSS темы)
### Requirement: Список тем конфигурируется
Система SHALL позволять задавать список тем через `hugo.toml`
(`[params.typewriter] words = [...]`), со встроенным фолбэком по умолчанию
(`linux, llm, politics, homelab, nix`) при его отсутствии.
#### Scenario: Слова из конфига
- **GIVEN** в `hugo.toml` задан `[params.typewriter] words = ["linux","llm","politics"]`
- **WHEN** страница собрана и открыта
- **THEN** анимация печатает именно эти слова, в заданном порядке
#### Scenario: Конфиг не задан
- **GIVEN** в `hugo.toml` нет `[params.typewriter]`
- **WHEN** страница собрана и открыта
- **THEN** используется фолбэк-список по умолчанию, анимация работает
@@ -0,0 +1,37 @@
# Tasks: Header Typewriter
## 1. Реализация
- [x] 1.1 Создать `assets/css/extended/typewriter.css` — стили контейнера
`#dedinit-typewriter` и блочного курсора `.tw-cursor` (мигание через
`@keyframes`, `prefers-reduced-motion` отключает анимацию)
- [x] 1.2 Создать `layouts/partials/extend_footer.html` — инлайн-скрипт цикла
печати/стирания слов (тайминги: type ~90ms, hold ~1400ms, erase ~50ms,
gap ~400ms), вставка контейнера в логотип `dedinit()`, обработка
`prefers-reduced-motion`, фолбэк-список слов
- [x] 1.3 Проверить, что `extend_footer.html` реально вызывается темой:
`grep -n "extend_footer" themes/rDedInit/layouts/partials/footer.html`
## 2. Сборка и проверка
- [x] 2.1 Собрать сайт: `make build` — без ошибок (exit 0)
- [x] 2.2 Проверить в `public/index.html` наличие `dedinit-typewriter`
(`grep -c "dedinit-typewriter" public/index.html` >= 1)
- [x] 2.3 Проверить наличие `.tw-cursor` и `prefers-reduced-motion` в собранном CSS
(`grep -c "tw-cursor" public/**/*.css` >= 1)
- [x] 2.4 Логика цикла проверена эмуляцией (node): печать до полного слова,
пауза, стирание до пустоты, пауза-мигание, переход к следующему;
после последнего слова цикл замыкается; все слова проходят полный цикл
(см. /tmp/tw_test.js)
## 3. Git
- [ ] 3.1 Закоммитить изменения (layouts/, assets/, openspec/, при желании
hugo.toml с [params.typewriter]) и запушить в gitverse (после
согласования с пользователем)
- [ ] 3.2 Обновить `docs/STATUS.md` / `docs/TODO.md` при необходимости
## 4. Деплой (по согласованию)
- [ ] 4.1 `make deploy` (rsync public/ → kpa39l.myjino.ru:2222)
- [ ] 4.2 Проверить на https://dedinit.ru/ — логотип печатает темы