[{"data":1,"prerenderedAt":169},["ShallowReactive",2],{"journal-all":3,"journal-post-odin-audio-context-dlya-vseh-zvukov":166},[4,14,23,32,41,50,59,68,77,86,95,104,113,122,131,139,148,157],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":13},83,"pg9d6k0p2ggrjlpwnp0vmfhu","Публичный контракт вместо внутренних формул в typographics 5","Выпустил `typographics@5.0.0`. Изменение в самом CSS небольшое — блоки кода теперь по умолчанию используют ту же плавную базу, что и основной текст. Это хорошо закрывает задачу, над которой пакет постепенно менялся после 3.0 — пользователю не нужно знать внутренние формулы и пути сборки без необходимости.\n\nВ 3.0 убрал `html { font-size: 10px }` и разделил основной текст и заголовки на две плавные шкалы. Сама модель стала аккуратнее, но пользоваться ей всё ещё можно на разных уровнях. Иногда нужно просто сделать типографику чуть меньше внутри статьи. Иногда — задать точные размеры одному заголовку на краях адаптивного диапазона. А подключение пакета вообще не должно требовать знания, где именно лежит собранный CSS.\n\nЗа несколько релизов всё это сложилось в отдельный публичный контракт: общие коэффициенты, точные границы, импорт из корня пакета и явные способы переопределения.\n\n## Масштаб без копирования формулы\n\nПосле 3.0 размер основного текста вычислялся через `--t-body-font-size-clamp`, а заголовков — через `--t-heading-font-size-clamp`. Если хотелось уменьшить всю типографику, технически можно было переопределить исходные `min`\u002F`max`. Но это слишком низкий уровень для обычной задачи \"сделать этот блок на 5% компактнее\".\n\nВ `3.1.0` добавил две переменные:\n\n```scss\n:root {\n  --t-body-scale: 1;\n  --t-heading-scale: 1;\n}\n```\n\nОни применяются уже поверх рассчитанной fluid-базы:\n\n```scss\nfont-size: calc(\n  var(--t-heading-font-size-clamp)\n  * var(--t-heading-scale, 1)\n  * #{$k}\n);\n```\n\nДля ролей основного текста схема такая же:\n\n```scss\nfont-size: calc(\n  var(--t-body-font-size-clamp)\n  * var(--t-body-scale, 1)\n  * #{$k}\n);\n```\n\nЗа счёт каскада одна и та же настройка работает и глобально, и локально:\n\n```css\n:root {\n  --t-body-scale: 0.9;\n  --t-heading-scale: 0.9;\n}\n\n.article {\n  --t-body-scale: 0.95;\n}\n\n.hero {\n  --t-heading-scale: 0.9;\n}\n```\n\nПолучился нужный уровень API: не копировать `clamp()` и внутренние `calc()`, а указать, какую группу текста нужно масштабировать. Внутренняя математика остаётся внутри пакета.\n\nПатч `3.0.1` перед этим ничего глобально в CSS не менял — я только исправил примеры в README. Реальная настройка новой модели началась в `3.1.0`.\n\n## Когда общего масштаба заголовков недостаточно\n\nОбщий коэффициент решает много случаев, но реальные макеты показали его границу. Иногда разные роли заголовков должны иметь разные адаптивные диапазоны. Уменьшить всю шкалу заголовков на `0.9` недостаточно, если, например, только `.headline-medium` должен быть `28px` на узком экране и `34px` на широком.\n\nВ `3.2.0` добавил необязательные границы для каждой роли заголовка:\n\n```css\n.headline-medium {\n  --t-headline-medium-min: 28px;\n  --t-headline-medium-max: 34px;\n}\n```\n\nВнутри миксин сначала пытается взять эти значения, а если их нет — возвращается к общей шкале заголовков:\n\n```scss\n--t-heading-resolved-min: var(\n  --t-#{$fluid-key}-min,\n  calc(\n    var(--t-heading-font-size-min)\n    * var(--t-heading-scale, 1)\n    * #{$k}\n  )\n);\n\n--t-heading-resolved-max: var(\n  --t-#{$fluid-key}-max,\n  calc(\n    var(--t-heading-font-size-max)\n    * var(--t-heading-scale, 1)\n    * #{$k}\n  )\n);\n```\n\nДальше эти два значения становятся границами обычного `clamp()`.\n\nНовый API не заставляет настраивать каждую роль вручную. По умолчанию всё продолжает следовать общей шкале. Точные `min`\u002F`max` нужны только там, где макет действительно этого требует.\n\nПеред стабильным релизом опубликовал `3.2.0-dev.0` для обкатки. В финальном `3.2.0` общая шкала заголовков остаётся вариантом по умолчанию, а отдельные роли при необходимости получают собственные границы.\n\n## Подключение пакета тоже часть API\n\nВ `4.0.0` типографический CSS относительно `3.2.0` не изменился. Мажорный релиз был про то, как пакет приезжает к пользователю.\n\nК этому моменту я унифицировал npm-пакеты своего портфолио вокруг общего стандарта сборки и публикации. Для `typographics` это означало, в частности, корневой CSS export:\n\n```json\n{\n  \"style\": \".\u002Fdist\u002Findex.css\",\n  \"exports\": {\n    \".\": {\n      \"style\": \".\u002Fdist\u002Findex.css\",\n      \"default\": \".\u002Fdist\u002Findex.css\"\n    },\n    \".\u002Fdist\u002F*\": \".\u002Fdist\u002F*\"\n  }\n}\n```\n\nВ README основной способ подключения после этого выглядит так:\n\n```js\nimport 'typographics';\n```\n\nПуть к файлу внутри `dist` всё ещё доступен, но для обычного сценария больше не нужен.\n\nКорневой экспорт теперь проверяется вместе с собранным пакетом, поэтому основной способ подключения не зависит от внутреннего пути в `dist`.\n\nМажорная версия понадобилась из-за изменения публичного способа подключения и требований пакета. CSS при этом остался прежним.\n\n## Блок кода следует основному тексту\n\nК `5.0.0` в плавной модели оставалось заметное исключение. Основной текст, абзацы и списки уже зависели от `--t-body-font-size-clamp`, а блок кода жил от фиксированного значения `1.4rem`:\n\n```scss\nfont-size: calc(\n  var(--t-code-block-font-size, 1.4rem)\n  * var(--t-body-scale, 1)\n);\n```\n\nВ 5.0 значение по умолчанию теперь берётся из базы основного текста:\n\n```scss\n:root {\n  --t-code-block-scale: 1;\n}\n\n@mixin typography-code-block() {\n  font-size: var(\n    --t-code-block-font-size,\n    calc(\n      var(--t-body-font-size-clamp)\n      * var(--t-body-scale, 1)\n      * var(--t-code-block-scale, 1)\n    )\n  );\n}\n```\n\nЗдесь получились три уровня управления.\n\nЕсли ничего не задавать, блок кода следует плавному размеру основного текста. Если нужно чуть изменить только код, достаточно `--t-code-block-scale`:\n\n```css\n.article {\n  --t-code-block-scale: 0.9;\n}\n```\n\nЕсли нужен полностью фиксированный или собственный размер, остаётся явное переопределение:\n\n```css\n.article {\n  --t-code-block-font-size: 13px;\n}\n```\n\nПоследний вариант заменяет плавный расчёт целиком. Значение по умолчанию следует общей модели, но пакет не заставляет пользователя оставаться внутри неё.\n\nДля `typographics` такая схема оказалась удобнее, чем выдавать наружу набор внутренних формул. Сначала есть простой уровень настройки группы, затем точная настройка конкретной роли, а если нужен другой режим целиком — явное переопределение.\n\nК `5.0.0` публичная поверхность выглядит достаточно цельно. `--t-body-scale` и `--t-heading-scale` решают грубую настройку, `min`\u002F`max` конкретной роли — точную, блок кода наследует общую плавную базу, а импорт начинается с имени пакета. Внутри по-прежнему есть `clamp()`, коэффициенты и резервные формулы, но пользоваться библиотекой можно, почти не зная их устройства.\n\n## Материалы\n\n- [CSS Custom Properties for Cascading Variables Level 1](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-variables-1\u002F)\n","publichnyj-kontrakt-vmesto-vnutrennih-formul-v-typographics-5","Maxim","2026-09-03","2026-09-29T13:33:58.338Z",[],{"id":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":22},78,"rrs7fbiul9xgn1lqvzvfkv40","Кэш не должен ломать загрузку спрайта","`iconly 5.0.0`. В этот раз почти не добавлял новых возможностей. Вместо этого прошёлся по поведению, которое обычно становится заметно, когда ломается что-то рядом: хранилище недоступно, в кэше лежит плохой SVG, пользовательский обработчик бросает исключение или два `init()` запускаются одновременно.\n\nСформулировал для себя правило — кэш должен помогать загрузке спрайта, но не должен быть условием успеха загрузки.\n\nВ `4.2` это правило не выполнялось. Если чтение IndexedDB или другого хранилища возвращало ошибку, `init()` завершался с `ok: false`. Ошибка записи после успешного `fetch` делала то же самое. Получалось, что необязательная оптимизация могла остановить основную операцию, хотя сеть и DOM оставались полностью рабочими.\n\nВ `5.0` меняю именно этот контракт.\n\n## У одной версии может быть несколько файлов\n\nДо этого запись кэша определялась только значением `version`:\n\n```ts\nconst cacheResult = await storage.get(resolved.version);\n```\n\nПри записи использовался тот же ключ:\n\n```ts\nawait storage.set({\n  version: resolved.version,\n  data,\n});\n```\n\nПока на странице один файл спрайта, этого достаточно. Но такой ключ не описывает сам ресурс. Два загрузчика с разными файлами и одинаковой версией `1.0` могут обратиться к одной и той же записи.\n\nНапример:\n\n```ts\ncreateIconly({\n  file: '\u002Fadmin-icons.svg',\n  version: '1.0',\n});\n\ncreateIconly({\n  file: '\u002Fshop-icons.svg',\n  version: '1.0',\n});\n```\n\nРаньше для хранилища это были два запроса к ключу `1.0`.\n\nВ `5.0` ключ кэша строится из URL файла и версии:\n\n```ts\nconst createCacheKey = (\n  file: string,\n  version: string,\n  baseUrl: string,\n): string => {\n  try {\n    return JSON.stringify([new URL(file, baseUrl).href, version]);\n  } catch {\n    return JSON.stringify([file, version]);\n  }\n};\n```\n\nURL сначала нормализуется относительно `ownerDocument.baseURI`. Поэтому `\u002Ficons.svg`, переданный на странице с конкретным базовым URL, получает стабильную абсолютную форму. Если `new URL()` по какой-то причине не срабатывает, остаётся пара из исходного `file` и `version`.\n\nДля встроенных хранилищ это просто новый строковый ключ. С пользовательским `IconStorage` есть важная деталь: значение теперь нужно считать непрозрачным.\n\n```ts\nexport interface IconStorage {\n  get(key: string): Promise\u003CResult\u003CIconRecord | undefined>>;\n  set(record: IconRecord): Promise\u003CResult\u003Cvoid>>;\n}\n```\n\nПоле `IconRecord.version` пока сохраняю, но при записи туда попадает уже составной ключ. То есть пользовательское хранилище не должно разбирать его как пользовательскую версию набора иконок.\n\nЭто одна из причин, почему изменение выходит мажорным релизом. Форма `string` почти не изменилась, но смысл этой строки для внешней реализации хранилища изменился заметно.\n\n## Ошибка чтения кэша больше не блокирует загрузку\n\nСледующий вопрос оказался важнее самого ключа.\n\nВ предыдущей схеме ошибка чтения останавливала `init()`:\n\n```ts\nconst cacheResult = await storage.get(resolved.version);\n\nif (!cacheResult.ok) {\n  return fail(cacheResult.error);\n}\n```\n\nНо если IndexedDB временно недоступен, зачем из-за этого отказываться от SVG, который можно получить обычным `fetch()`?\n\nТеперь чтение кэша работает как необязательная попытка:\n\n```ts\nlet cacheAvailable = true;\nlet data: string | undefined;\n\ntry {\n  const cacheResult = await storage.get(cacheKey);\n\n  if (cacheResult.ok) {\n    data = cacheResult.value?.data;\n  } else {\n    cacheAvailable = false;\n    logError(cacheResult.error);\n  }\n} catch (error: unknown) {\n  cacheAvailable = false;\n  logError(\n    createIconlyError(\n      'storage_read_failed',\n      'Failed to read from icon storage.',\n      error,\n    ),\n  );\n}\n```\n\nЗдесь я специально обрабатываю оба варианта пользовательского хранилища: оно может корректно вернуть `Result` с ошибкой, а может неожиданно бросить исключение или вернуть отклонённый Promise.\n\nВ обоих случаях ошибка остаётся видимой через `onError` и `logger.error`, но основной сценарий продолжается и переходит к сети.\n\nПолучается немного непривычная, но полезная ситуация:\n\n```ts\nconst errors: IconlyError[] = [];\n\nconst iconLoader = createIconly({\n  file: '\u002Ficons.svg',\n  onError: (error) => errors.push(error),\n});\n\nconst result = await iconLoader.init();\n```\n\n`errors` может содержать `storage_read_failed`, а `result.ok` при этом быть `true`, если SVG удалось скачать и вставить.\n\nЭто не означает, что ошибка кэша игнорируется. Я просто перестаю смешивать два разных результата: состояние оптимизации и результат основной операции.\n\nЕсть ещё одно следствие. После неудачного чтения я не пытаюсь тут же делать `set()` в то же хранилище. На время текущего `init()` он считается недоступным:\n\n```ts\nif (cacheAvailable) {\n  \u002F\u002F storage.set(...)\n}\n```\n\nЕсли чтение уже показало, что хранилище не работает как ожидается, дополнительная запись редко улучшит ситуацию.\n\n## Ошибка записи тоже не отменяет успешную вставку\n\nТа же логика применяется к записи в кэш.\n\nВ `4.2` после `fetch` сначала выполнялся `storage.set()`. Если запись возвращала ошибку, `init()` завершался раньше вставки в DOM.\n\nТеперь последовательность другая:\n\n```text\nfetch\n  -> разбор SVG\n  -> sanitize \u002F защитная обработка\n  -> вставка в DOM\n  -> запись в кэш\n```\n\nСначала SVG должен успешно пройти тот путь, ради которого вообще существует загрузчик. Только после этого я пытаюсь сохранить исходную строку в хранилище.\n\n```ts\nconst insertResult = insertSvg(\n  containerResult.value,\n  fetchResult.value,\n  { sanitize: resolved.sanitize },\n);\n\nif (!insertResult.ok) {\n  return fail(insertResult.error);\n}\n\nif (cacheAvailable) {\n  const storeResult = await storage.set({\n    version: cacheKey,\n    data: fetchResult.value,\n  });\n\n  if (!storeResult.ok) {\n    logError(storeResult.error);\n  }\n}\n\nreturn ok(undefined);\n```\n\nПрактически здесь два изменения.\n\nВо-первых, ошибка записи больше не превращает уже успешную загрузку и вставку в `ok: false`. Пользователь получает иконки, а проблему с кэшем можно отдельно увидеть через обработчик ошибок.\n\nВо-вторых, некорректный загруженный SVG не попадает в хранилище до проверки. Если разбор или вставка не удались, запись вообще не выполняется.\n\nМне нравится это правило больше прежнего — сохранять только то, что текущая цепочка уже смогла использовать.\n\n## Данные из кэша тоже приходится проверять\n\nПереставить запись в кэш оказалось недостаточно. В хранилище уже может лежать плохая запись: старая версия приложения, ручное изменение, повреждённые данные или пользовательская реализация с неожиданным содержимым.\n\nРаньше найденная запись кэша воспринималась как готовый результат. Если сохранённая строка не разбиралась как SVG, `insertSvg()` возвращал `parse_error`, и на этом `init()` заканчивался.\n\nТеперь SVG из кэша сначала пробуется как обычно:\n\n```ts\nif (data) {\n  const insertResult = insertSvg(containerResult.value, data, {\n    sanitize: resolved.sanitize,\n  });\n\n  if (insertResult.ok) {\n    return ok(undefined);\n  }\n\n  if (insertResult.error.cause) {\n    return fail(insertResult.error);\n  }\n\n  logError(insertResult.error);\n}\n```\n\nЕсли строка просто не является валидным SVG-документом, ошибка логируется, а загрузчик переходит к сетевому запросу. Успешно полученная свежая копия затем заменяет запись.\n\nТак повреждённая запись кэша становится состоянием, из которого можно восстановиться, а не тупиком.\n\nПри этом я не хочу маскировать любое исключение под обычное отсутствие записи в кэше. Если ошибка вставки содержит `cause`, она остаётся критической. Например, пользовательский санитайзер может сам бросить исключение. В таком случае молча скачать тот же SVG ещё раз — не восстановление, а сокрытие реальной проблемы в пользовательском коде.\n\nТо есть запасной путь здесь довольно узкий: испорченный документ из кэша можно заменить свежим, неожиданную ошибку выполнения — нет.\n\n## Исключения не должны обходить Result\n\n`Result` появился в `3.0`. Тогда же я зафиксировал, что штатные ошибки `init()` возвращаются через `Result`, а не выбрасываются наружу. Но одно дело вернуть `Result` из собственных ожидаемых веток и другое — удержать этот контракт на всех границах.\n\nК `5.0` я нашёл несколько мест, где исключение всё ещё могло пройти мимо него.\n\nСамый простой пример — некорректный CSS-селектор:\n\n```ts\ncreateIconly({\n  container: '[',\n});\n```\n\n`document.querySelector('[')` бросает `DOMException`. Если вспомогательная функция не ловит его сама, никакого `Result` пользователь уже не получает.\n\nТеперь `resolveContainer()` оборачивает поиск в DOM и возвращает `container_invalid`:\n\n```ts\ntry {\n  \u002F\u002F document.querySelector(...)\n} catch (error: unknown) {\n  return err(\n    createIconlyError(\n      'container_invalid',\n      'Failed to resolve container.',\n      error,\n    ),\n  );\n}\n```\n\nТо же самое сделано вокруг разбора SVG и вставки в DOM.\n\nНо более интересная граница — пользовательские обработчики. Раньше такой код мог разрушить контракт ошибок самой библиотеки:\n\n```ts\ncreateIconly({\n  onError: () => {\n    throw new Error('reporter failed');\n  },\n});\n```\n\nТеперь обработчик и `logger` изолированы отдельно:\n\n```ts\ntry {\n  resolved.onError?.(error);\n} catch {\n  \u002F\u002F callback не должен менять Result\n}\n\ntry {\n  resolved.logger?.error?.('[Iconly error]', error);\n} catch {\n  \u002F\u002F logger тоже\n}\n```\n\nЗдесь мне важно, чтобы диагностический обработчик оставался именно обработчиком. Ошибка в нём не должна подменять исходную ошибку `init()`.\n\nНаконец, весь `runInit()` имеет последний защитный `try\u002Fcatch`. Для неожиданной ветки добавлен `unexpected_error`:\n\n```ts\ntry {\n  \u002F\u002F основной init flow\n} catch (error: unknown) {\n  return fail(\n    createIconlyError(\n      'unexpected_error',\n      'Unexpected error while initializing Iconly.',\n      error,\n    ),\n  );\n}\n```\n\nАналогичная защита появляется у синхронного `createSprite().render()`.\n\nЯ не пытаюсь доказать, что JavaScript-среда в принципе больше никогда не сможет бросить исключение. Задача практичнее: ожидаемые браузерные API, пользовательское хранилище и обработчики не должны случайно обходить публичный `Result`-контракт.\n\n## Два одновременных init() — одна операция\n\nЕщё один сценарий обнаруживается, если вызвать `init()` дважды, пока первый запрос ещё выполняется.\n\nВместо двух независимых операций теперь экземпляр хранит текущий Promise:\n\n```ts\nlet inFlight: Promise\u003CResult\u003Cvoid>> | null = null;\n\nconst init = (): Promise\u003CResult\u003Cvoid>> => {\n  if (!inFlight) {\n    inFlight = runInit().finally(() => {\n      controller = null;\n      inFlight = null;\n    });\n  }\n\n  return inFlight;\n};\n```\n\nПараллельные вызовы получают один и тот же Promise:\n\n```ts\nconst first = iconLoader.init();\nconst second = iconLoader.init();\n\nconsole.log(first === second); \u002F\u002F true\n```\n\nЭто заодно делает поведение `abort()` однозначнее во время активной загрузки: отменяется текущая общая операция, а не один из нескольких случайно запущенных запросов.\n\nПосле завершения `inFlight` сбрасывается, и следующий `init()` снова может выполнить обычную операцию.\n\n## IndexedDB тоже должен переживать существующую базу\n\nНа уровне адаптера я поправил ещё один сценарий. Раньше IndexedDB открывался с фиксированной версией `1`. Это неудобно, если база с таким `dbName` уже существует, но пользователь выбирает другой `storeName`.\n\nТеперь адаптер сначала открывает существующую базу без принудительной версии. Если нужного хранилища объектов нет, соединение закрывается, номер версии увеличивается и хранилище создаётся через `onupgradeneeded`.\n\nКроме того, соединение закрывается на `versionchange`, а отклонённый `dbPromise` сбрасывается, чтобы следующая попытка могла снова открыть базу.\n\nЭто не меняет публичный API хранилища, но соответствует тому же принципу релиза: хранилище не должно быть хрупким только потому, что окружение оказалось чуть сложнее идеального тестового сценария.\n\n## Что теперь означает ok: true\n\nВ `5.0` внешний код по-прежнему выглядит знакомо:\n\n```ts\nconst iconLoader = createIconly({\n  file: '\u002Ficons.svg',\n  version: '1.0',\n});\n\nconst result = await iconLoader.init();\n\nif (!result.ok) {\n  console.error(result.error);\n}\n```\n\nНо смысл `result.ok` теперь точнее.\n\n`ok: true` означает, что спрайт удалось получить и вставить. Это не обещание, что попутно идеально сработал кэш. Восстановимая ошибка хранилища может быть отдельно отправлена в `onError` или `logger`, не меняя успешный результат основной операции.\n\nРаньше хранилище было вынесено в отдельную абстракцию, но ядро всё ещё относилось к нему как к обязательной части успеха. Теперь зависимость стала честнее: кэш ускоряет следующий запуск, когда доступен, и отходит в сторону, когда нет.\n","kesh-ne-dolzhen-lomat-zagruzku-sprajta","2026-08-11","2026-09-29T13:33:08.016Z",[],{"id":24,"documentId":25,"title":26,"content":27,"slug":28,"author":10,"displayDate":29,"publishedAt":30,"tags":31},69,"v415k9na7rfuvuwadzaux2fq","playbackRate не вывозит","В `mode=\"scroll\"` лента должна ускоряться вместе со страницей и сохранять инерцию, когда прокрутка уже остановилась. После перехода на WAAPI скорость меняется через `playbackRate`. На бумаге это ровно то, что нужно — положительное значение двигает вперёд, отрицательное разворачивает, ноль ставит на паузу.\n\nНа поверке при смене направления лента дёргалась. Рывок был и на ПК, и на телефоне, во всех браузерах. Исправлять пришлось не формулу скорости, а способ, которым эта скорость доходит до уже запущенной анимации.\n\nРазберу, как режим `scroll` в `marquee-content@5.0.3` дошёл от ручного `requestAnimationFrame` до `element.animate()` и почему обычное присваивание `playbackRate` пришлось заменить на `updatePlaybackRate()`.\n\n## После GSAP\n\nВ `5.0.0` либа снова стала Custom Element. Я убрал GSAP как лишнюю зависимость и лишний вес. Постоянное движение (`mode=\"auto\"`) ушло в CSS `@keyframes`. Скорость задаётся в px\u002Fs, а не длительностью полного цикла.\n\nПодключение в текущей версии выглядит так:\n\n```html\n\u003Cmarquee-content speed=\"120\" mode=\"scroll\" direction=\"ltr\">\n  \u003Cspan>Discounts up to 40%\u003C\u002Fspan>\n  \u003Cspan>Free returns\u003C\u002Fspan>\n\u003C\u002Fmarquee-content>\n```\n\n`auto` браузер анимирует сам. В режиме `scroll` JavaScript слушает прокрутку `window`, оценивает скорость страницы и сдвигает дорожку. Именно этот режим пришлось доводить патчами.\n\n## Скорость страницы, а не длительность\n\nОбработчик считает вертикальную скорость в px\u002Fs и переводит её в добавку к масштабу времени:\n\n```ts\nconst velocity = deltaY \u002F deltaTime;\n\nthis.scrollDirectionFactor = velocity >= 0 ? 1 : -1;\n\nconst extraSpeed = this.clamp(\n  velocity \u002F this.options.scrollVelocityFactor,\n  -this.options.scrollMaxExtraSpeed,\n  this.options.scrollMaxExtraSpeed,\n);\n```\n\n`scrollVelocityFactor` по умолчанию равен `150` — прокрутка `300px` в секунду даёт добавку `2`. Значение ограничено диапазоном от `-5` до `5`. Если модуль добавки меньше `0.05`, дополнительное ускорение сбрасывается. Затухание идёт по `easeOutCubic` за `2500ms`.\n\nИтог — безразмерный `timeScale`. Он учитывает базовое направление ленты (`rtl` \u002F `ltr`), знак прокрутки и затухающую добавку к скорости. В `5.0.0` этот масштаб сразу умножался на смещение за кадр.\n\n## Сначала сгладить, потом отдать движение браузеру\n\nРанний цикл прокрутки каждый кадр делал три вещи: считал новую позицию, писал `transform`, планировал следующий `requestAnimationFrame`.\n\n```ts\nthis.offset = this.normalizeOffset(\n  this.offset + currentSpeed * timeScale * deltaTime,\n);\nthis.applyTransform();\n```\n\n`applyTransform()` выставлял `translate3d(...)` напрямую. Масштаб времени прыгал к целевому значению за один кадр, поэтому смена направления выглядела резче, чем сама прокрутка.\n\nПеред `5.0.2` масштаб начал догонять цель экспонентой с постоянной `0.12s`:\n\n```ts\nconst smoothingFactor = 1 - Math.exp(-deltaTime \u002F 0.12);\n\nthis.currentTimeScale += (targetTimeScale - this.currentTimeScale) * smoothingFactor;\n```\n\nНа кадре `16ms` коэффициент около `0.125`: лента не разворачивается мгновенно, но и не тянется секундами. Пока сглаживание не сошлось, цикл продолжается. Параллельно на дорожку повесил `backface-visibility: hidden`, чтобы убрать мерцание при частых перерисовках.\n\nСглаживание помогло, но не убрало главную цену цикла `requestAnimationFrame`: JavaScript по-прежнему сам рассчитывал позицию и писал `transform` на каждом кадре. Лента не могла двигаться без очередного вызова на главном потоке.\n\n## Движение через element.animate()\n\nВ `5.0.2` постоянное смещение ушло в WAAPI. Дорожка получает одну бесконечную линейную анимацию на дистанцию группы:\n\n```ts\nconst animation = this.track.animate(\n  [\n    { transform: 'translate3d(0px, 0, 0)' },\n    { transform: `translate3d(${-this.distance}px, 0, 0)` },\n  ],\n  {\n    duration: durationMs,\n    iterations: Number.POSITIVE_INFINITY,\n    easing: 'linear',\n  },\n);\n\nanimation.currentTime = durationMs * (500 + progress);\nanimation.playbackRate = this.currentTimeScale;\n```\n\nДлительность считается из ширины группы и текущей скорости в px\u002Fs. `500` полных циклов в стартовом `currentTime` нужны как запас: при отрицательном `playbackRate` время идёт назад, и анимация не должна сразу упереться в ноль.\n\n`requestAnimationFrame` после этого не двигает ленту. Он только сглаживает `currentTimeScale` и, пока значение не устаканилось продолжает вызываться. Когда дополнительное ускорение погасло и масштаб сошёлся с целью, цикл останавливается, а браузер продолжает анимацию без покадровых записей из JavaScript.\n\nЭто как раз то разделение, которого не хватало ручному `translate3d`: геометрию ведёт браузер, скрипт трогает только скорость.\n\n## Почему playbackRate почти подходит\n\nСкорость живой анимации в `5.0.2` менялась обычным присваиванием:\n\n```ts\nif (this.scrollAnimation.playbackRate !== this.currentTimeScale) {\n  this.scrollAnimation.playbackRate = this.currentTimeScale;\n}\n```\n\nНа старте, пока анимация ещё создаётся, так и нужно: `currentTime` только что задан, синхронизировать нечего. Ошибка была в покадровом пути. Сглаживание меняет масштаб десятки раз за разворот. Каждый раз присваивание бьёт в уже идущую анимацию.\n\nСпецификация Web Animations описывает это прямо. Установка `playbackRate` — синхронное обновление без попытки согласовать состояние с анимацией, которая может идти в другом потоке или процессе. В результате живая анимация может скакнуть. Чтобы скорость обновилась без скачка, спецификация указывает асинхронный `updatePlaybackRate()`.\n\nСмена направления как раз меняет знак масштаба. Инерция ещё не дошла до нуля, целевое значение уже отрицательное, сглаживание тащит `currentTimeScale` через ноль. На этом участке присваивание повторяется каждый кадр и даёт заметный рывок.\n\nВозврат к ручному `offset` снова заставил бы главный поток писать `transform`. Дополнительное сглаживание тоже не устраняло причину: оно лишь увеличивало число записей в `playbackRate`.\n\n## updatePlaybackRate() сохраняет позицию\n\nВ `5.0.3` покадровый путь делает одну замену:\n\n```ts\nif (this.scrollAnimation.playbackRate !== this.currentTimeScale) {\n  this.scrollAnimation.updatePlaybackRate(this.currentTimeScale);\n}\n```\n\nМетод сначала согласовывает позицию, затем выставляет скорость. `playbackRate` после вызова обновляется не сразу, а когда выполнится `ready`. Для разворота это и нужно: лента не прыгает к другому кадру цикла, меняется только темп.\n\nСоздание анимации по-прежнему начинается с присваивания. Там `playbackRate` задаёт начальную скорость вместе с посеянным `currentTime`, а не корректирует полёт.\n\nРучная проверка того же сценария — прокрутка вверх и вниз на телефоне и на ПК — больше не даёт рывка. Отдельного списка движков нет — дефект был везде, пока стояло присваивание.\n\n## Что осталось на JavaScript\n\n`mode=\"auto\"` после `5.0.0` JavaScript почти не трогает: пауза, `prefers-reduced-motion` и пересборка дорожки. Режиму `scroll` всё ещё нужны: обработчик прокрутки, оценка скорости и короткий цикл `requestAnimationFrame` (пока экспонента не сошлась).\n\nВ этой реализации нельзя убрать `requestAnimationFrame` после перехода на WAAPI без потери выбранного сглаживания. Без промежуточных кадров `playbackRate` скакал бы к цели за один шаг, даже если обновлять его правильно. Компромисс такой: браузер ведёт бесконечный сдвиг, скрипт вмешивается только пока инерция ещё жива.\n\nФормулы скорости прокрутки и затухания дополнительного ускорения остались от `5.0.0`. Добавилось сглаживание масштаба, а затем изменился способ, которым результат доходит до уже запущенной анимации.\n\n## Материалы\n\n- [Web Animations: Animation.playbackRate](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fweb-animations-1\u002F#dom-animation-playbackrate)\n- [Web Animations: updatePlaybackRate()](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fweb-animations-1\u002F#dom-animation-updateplaybackrate)\n- [MDN: Animation.updatePlaybackRate()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAnimation\u002FupdatePlaybackRate)\n- [MDN: Element.animate()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FElement\u002Fanimate)\n- [MDN: Animation.playbackRate](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAnimation\u002FplaybackRate)\n","playback-rate-ne-vyvozit","2026-08-08","2026-09-29T13:31:39.134Z",[],{"id":33,"documentId":34,"title":35,"content":36,"slug":37,"author":10,"displayDate":38,"publishedAt":39,"tags":40},73,"ashjhdkt56ram23r40w9vx7e","Два пути к SVG-спрайту в Iconly","После `3.0` у iconly появился довольно понятный контракт загрузки: `createIconly()` проверяет кэш, при необходимости получает внешний SVG-файл и вставляет его в DOM. Но в этой схеме всё ещё зашито одно предположение: источник иконок — обязательно готовый файл со спрайтом.\n\nДля части проектов это удобно. Для другой части иконки уже живут как ESM-модули рядом с остальным кодом, и собирать из них отдельный файл только затем, чтобы iconly снова его загрузил, выглядит лишним кругом. Поэтому в `4.1.0` я добавил второй публичный путь — `iconly\u002Fsprite`. Он принимает уже импортированные объекты иконок, собирает из них SVG и при необходимости вставляет его в тот же DOM-контейнер.\n\nЧерез несколько дней, в `4.2.0`, я отдельно занялся другой общей границей: любая строка SVG, которую библиотека собирается добавить в DOM страницы, должна проходить хотя бы базовую защитную обработку. При этом встроенную очистку я сознательно не называю полноценной санитизацией и оставляю хук для специализированной обработки.\n\n## Перед вторым входом в пакет\n\nВ `4.0.0` iconly заодно переехал на общий для портфолио стек сборки и проверки: tsdown, ESM\u002FCJS, smoke-тесты и проверка tarball, публикация через Trusted Publishing. Подробно описывать этот инфраструктурный переход здесь не буду. Для следующего изменения важна одна вещь: пакет уже проверяет не только корневой импорт, поэтому второй публичный подпуть можно добавить как полноценную часть `exports`, типов и smoke-тестов.\n\n## Внешний файл больше не единственный источник\n\nДо `4.1` обычный сценарий остаётся таким:\n\n```ts\nimport { createIconly } from 'iconly';\n\nconst iconLoader = createIconly({\n  file: '.\u002Fsprite.svg',\n  version: '1.0',\n  storage: 'indexeddb',\n});\n\nconst result = await iconLoader.init();\n```\n\nЗдесь iconly занимается всем жизненным циклом внешнего ресурса: кэшем, загрузкой, разбором SVG и вставкой в DOM.\n\nНо если в приложении иконки уже представлены модулями, мне нужен другой уровень API. Не загрузчик, а небольшой сборщик, которому можно передать только нужные иконки:\n\n```ts\nimport { createSprite } from 'iconly\u002Fsprite';\nimport { search, user, trash } from '.\u002Ficons';\n\nconst sprite = createSprite({\n  icons: [search, user, trash],\n  container: '#app',\n});\n\nconst result = sprite.render();\n\nif (!result.ok) {\n  console.error(result.error);\n}\n```\n\nФормат одной иконки намеренно маленький:\n\n```ts\nexport interface IconlyIcon {\n  name: string;\n  viewBox: string;\n  body: string;\n}\n```\n\n`name` становится `id` у `\u003Csymbol>`, `viewBox` переносится в одноимённый SVG-атрибут, `body` остаётся содержимым `\u003Csymbol>`.\n\nНовый путь вообще не использует сеть и хранилище. Он работает с данными, которые приложение уже импортировало.\n\n## Отдельный подпуть вместо расширения основного импорта\n\nЯ не добавляю сборщик спрайта в корневой импорт. Для него появляется отдельный подпуть:\n\n```ts\nimport { createSprite, buildSpriteString } from 'iconly\u002Fsprite';\n```\n\nВ `package.json` это отдельный экспорт:\n\n```json\n{\n  \"exports\": {\n    \".\u002Fsprite\": {\n      \"import\": {\n        \"types\": \".\u002Fdist\u002Fsprite.d.ts\",\n        \"default\": \".\u002Fdist\u002Fsprite.js\"\n      },\n      \"require\": {\n        \"types\": \".\u002Fdist\u002Fsprite.d.cts\",\n        \"default\": \".\u002Fdist\u002Fsprite.cjs\"\n      }\n    }\n  }\n}\n```\n\nА tsdown получает вторую точку входа:\n\n```ts\nexport default defineLibrary({\n  platform: 'browser',\n  entry: {\n    index: 'src\u002Findex.ts',\n    sprite: 'src\u002Fsprite.ts',\n  },\n});\n```\n\nЭто позволяет не смешивать две модели использования. `iconly` остаётся API загрузки внешнего файла. `iconly\u002Fsprite` — API сборки спрайта из уже имеющихся объектов иконок.\n\nЗаодно smoke-тесты проверяют четыре новых артефакта: ESM, CJS и оба файла типов. После работы в `4.0` добавление нового подпути уже не заканчивается на \"локально импортируется\".\n\n## buildSpriteString не требует DOM\n\nВторой API внутри подпути ещё проще:\n\n```ts\nimport { buildSpriteString } from 'iconly\u002Fsprite';\n\nconst svg = buildSpriteString([search, user, trash]);\n```\n\nРеализация почти буквально собирает `\u003Csymbol>`:\n\n```ts\nexport const buildSpriteString = (icons: IconlyIcon[]): string => {\n  const symbols = icons\n    .map(\n      (icon) =>\n        `\u003Csymbol id=\"${escapeAttr(icon.name)}\" viewBox=\"${escapeAttr(icon.viewBox)}\">${icon.body}\u003C\u002Fsymbol>`,\n    )\n    .join('');\n\n  return `\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\">${symbols}\u003C\u002Fsvg>`;\n};\n```\n\nЗдесь нет обращения к `document`, сети или хранилищу. Поэтому функцию можно использовать там, где нужен только результат строкой, например в SSR или в тесте.\n\nПустой массив тоже имеет определённый результат:\n\n```html\n\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\">\u003C\u002Fsvg>\n```\n\nДля `name` и `viewBox` сборщик экранирует специальные символы. `body` при этом не превращается в текст, потому что это и есть SVG-разметка конкретной иконки.\n\n## Tree-shaking начинается до iconly\n\nОдна из причин держать этот путь на уровне объектов иконок — он естественно сочетается с ESM-импортами:\n\n```ts\nimport { search, user } from '.\u002Ficons';\n\nbuildSpriteString([search, user]);\n```\n\nНо здесь важно точно разделять ответственность. Сам iconly не запускает tree-shaking и не анализирует каталог иконок. Он получает уже выбранный массив.\n\nУдалить неиспользованные экспорты может сборщик приложения, если пакет иконок организован подходящим образом. Задача `iconly\u002Fsprite` гораздо проще: не заставлять потребителя сначала собирать общий внешний файл спрайта, если нужные объекты иконок уже находятся в графе модулей.\n\n## Два пути используют одну вставку в DOM\n\nПри добавлении `createSprite()` я не хочу получить вторую реализацию вставки SVG.\n\nВ `4.1` `resolveContainer()` переезжает из `core.ts` в `dom.ts`, теперь его используют оба сценария. Сам `createSprite()` заканчивается тем же `insertSvg()`, что и загрузчик:\n\n```ts\nreturn insertSvg(\n  containerResult.value,\n  buildSpriteString(icons),\n);\n```\n\nПолучаются две разные цепочки до DOM:\n\n```text\ncreateIconly()\n  кэш -> fetch -> строка SVG\n                    |\n                    v\n                 insertSvg()\n\ncreateSprite()\n  объекты иконок -> buildSpriteString()\n                    |\n                    v\n                 insertSvg()\n```\n\nДля меня это важнее, чем просто вынести ещё пару функций в отдельный файл. Источник данных может быть разным, но правила выбора контейнера, разбора SVG и вставки не должны расходиться без причины.\n\n## Перед вставкой SVG нужна отдельная граница\n\nВ `2.0` я уже ушёл от `insertAdjacentHTML` к `DOMParser`, но разбор сам по себе не делает входной SVG безопасным. К `4.2.0` я добавил отдельную обработку перед тем, как разобранный элемент попадёт в DOM страницы.\n\nТеперь `insertSvg()` принимает необязательные параметры:\n\n```ts\nexport interface InsertSvgOptions {\n  sanitize?: SvgSanitizer;\n}\n```\n\nПорядок такой:\n\n```text\nSVG string\n  -> пользовательская функция sanitize, если задана\n  -> DOMParser\n  -> встроенная защитная обработка SVG\n  -> document.importNode()\n  -> DOM страницы\n```\n\nОдин и тот же путь используется и для внешнего файла, и для `createSprite().render()`.\n\nПублично `sanitize` подключается одинаково:\n\n```ts\nconst iconLoader = createIconly({\n  file: '.\u002Fsprite.svg',\n  sanitize,\n});\n```\n\n```ts\nconst sprite = createSprite({\n  icons: [search, user],\n  sanitize,\n});\n```\n\nПользовательская функция вызывается до разбора SVG. Это оставляет возможность подключить специализированный санитайзер или собственные правила обработки строки, не зашивая стороннюю зависимость в iconly.\n\n## Что делает встроенная защитная обработка\n\nПосле разбора библиотека рекурсивно проходит дерево SVG.\n\nВ текущей версии удаляются:\n\n- `\u003Cscript>`\n- `\u003CforeignObject>`\n- атрибуты вида `onerror`, `onclick` и другие обработчики `on*`\n- `href` и `xlink:href` со схемами `javascript:` и `data:text\u002Fhtml`\n- элементы SMIL-анимации, которые пытаются менять `href` или `xlink:href`.\n\nПоследний пункт сделан не как запрет всей SVG-анимации. Например, `animate` для `opacity` остаётся допустимым. Тесты отдельно фиксируют это различие.\n\nЕсть и интеграционные тесты для обоих публичных сценариев. Внешний SVG с `onerror` проходит через `createIconly()`, `body` иконки с тем же атрибутом — через `createSprite()`. В обоих случаях обработчик должен исчезнуть до вставки.\n\nТо есть защитная обработка привязана не к тому, откуда пришёл SVG, а к общей операции \"сейчас эта структура попадёт в DOM\".\n\n## Это не полноценный санитайзер\n\nЗдесь я не хочу \"переобещать\".\n\nВстроенный проход закрывает несколько очевидных опасных конструкций, но не пытается реализовать полноценный санитайзер для всего пространства SVG\u002FHTML. В README я прямо оставил примеры того, что он не покрывает полностью: CSS-векторы внутри `\u003Cstyle>` и внешние ссылки в `\u003Cuse>` или `\u003Cimage>`.\n\nПоэтому для недоверенного содержимого есть хук `sanitize`. Например, туда можно подключить специализированную библиотеку, а встроенный проход оставить дополнительным слоем перед вставкой.\n\nЕсть ещё одна граница: `buildSpriteString()` возвращает строку как есть и вообще не касается DOM. Поэтому встроенная защитная обработка там не выполняется.\n\n```ts\nconst svg = buildSpriteString(untrustedIcons);\n```\n\nЕсли такую строку дальше использовать самостоятельно, ответственность за очистку остаётся у вызывающего кода. В `4.2` сборщик дополнительно экранирует `>` в `name` и `viewBox`, но `icon.body` по-прежнему остаётся SVG-разметкой.\n","dva-puti-k-svg-sprajtu-v-iconly","2026-07-11","2026-09-29T13:32:18.413Z",[],{"id":42,"documentId":43,"title":44,"content":45,"slug":46,"author":10,"displayDate":47,"publishedAt":48,"tags":49},82,"jesxp2we692xubc1lvx26tvm","Полируем clicktone","После изменений в Web Audio поведение clicktone меня устраивает гораздо больше, чем инфраструктура вокруг пакета.\n\nЗдесь есть неприятный класс ошибок: исходники проходят юнит-тесты и проверку типов, сборка завершается успешно, а опубликованный пакет всё равно оказывается сломан для одного из способов импорта. Пользователь ведь устанавливает не `src`. Он получает конкретный архив с JavaScript, декларациями типов и `package.json`.\n\nВ `3.0.x` API воспроизведения почти не менял. Вместо этого хочу сделать проверяемым именно тот артефакт, который уходит в npm.\n\n## Убрать одинаковые правила из репозитория\n\nДо этого clicktone собирался в режиме библиотеки Vite. Локальный конфиг описывал ESM, CommonJS и выходы UMD, подключал `vite-plugin-dts`, задавал имена файлов и часть настроек Rollup.\n\nДля одной библиотеки такая конфигурация вполне терпима. Когда пакетов несколько, одинаковые правила начинают копироваться между репозиториями и понемногу расходиться.\n\nВ `3.0.0` сборка переехала на `tsdown`, а общие настройки — в отдельные пакеты конфигов. Локальный конфиг сборки после этого стал коротким:\n\n```ts\nimport { defineLibrary } from '@ux-ui\u002Ftsdown-config';\n\nexport default defineLibrary({\n  platform: 'browser',\n  entry: { index: 'src\u002Fmain.ts' },\n});\n```\n\nТо же самое с TypeScript и Biome:\n\n```json\n{\n  \"extends\": \"@ux-ui\u002Ftsconfig-base\u002Ftsconfig.json\",\n  \"include\": [\"src\", \"tsdown.config.ts\", \"vitest.config.ts\"]\n}\n```\n\n```json\n{\n  \"extends\": [\"@ux-ui\u002Fbiome-config\u002Fbiome\"]\n}\n```\n\nСмысл здесь не в замене одного бандлера другим. Из clicktone исчезают правила, которые вообще не относятся к его звуковому API. Если настройка одинакова для нескольких npm-библиотек, поддерживать её удобнее в одном месте.\n\n## package.json должен совпадать со сборкой\n\nПосле унификации сборки я заодно сузил публичную поверхность пакета.\n\nОсновная точка входа в `3.0.0` описана так:\n\n```json\n{\n  \"main\": \".\u002Fdist\u002Findex.cjs\",\n  \"module\": \".\u002Fdist\u002Findex.js\",\n  \"types\": \".\u002Fdist\u002Findex.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"import\": {\n        \"types\": \".\u002Fdist\u002Findex.d.ts\",\n        \"default\": \".\u002Fdist\u002Findex.js\"\n      },\n      \"require\": {\n        \"types\": \".\u002Fdist\u002Findex.d.cts\",\n        \"default\": \".\u002Fdist\u002Findex.cjs\"\n      }\n    }\n  }\n}\n```\n\nДля `import` есть ESM-код и соответствующая декларация типов. Для `require` — CJS-код и отдельный `.d.cts`.\n\nЭто та часть пакета, которую легко недооценить, если проверять только факт генерации `.d.ts`. TypeScript и Node идут по разным веткам `exports`, поэтому сами пути тоже являются частью контракта.\n\nПубличный `.\u002Fdist\u002F*` я убрал. Внутренние файлы не стоит случайно превращать во внешний API только потому, что они лежат в опубликованной папке.\n\nСостав пакета тоже ограничил явно:\n\n```json\n{\n  \"files\": [\"dist\", \"README.md\", \"LICENSE\"]\n}\n```\n\nИсходники, тесты и локальные конфиги остаются в репозитории, но пользователю библиотеки они не нужны.\n\n## Smoke test уже по dist\n\nЮнит-тесты clicktone проверяют поведение исходного кода. После сборки появляется другой набор вопросов: создались ли нужные файлы и можно ли реально открыть обе точки входа.\n\nДля этого оставил отдельный smoke test поверх `dist`:\n\n```js\nconst expectedArtifacts = [\n  'dist\u002Findex.js',\n  'dist\u002Findex.cjs',\n  'dist\u002Findex.d.ts',\n  'dist\u002Findex.d.cts',\n];\n\nfor (const artifact of expectedArtifacts) {\n  assert.equal(existsSync(artifact), true);\n}\n\nconst esm = await import('..\u002Fdist\u002Findex.js');\nassert.equal(typeof esm.ClickTone, 'function');\n\nconst cjs = await import('..\u002Fdist\u002Findex.cjs');\nassert.equal(typeof cjs.ClickTone, 'function');\n```\n\nОн нарочно простой. Повторять здесь все юнит-тесты не нужно. Smoke test отвечает только за собранный слой: ожидаемые файлы существуют, ESM открывается, CJS открывается.\n\n## Но локальный dist — ещё не npm-пакет\n\nДаже после этого можно ошибиться в `exports`, `files` или декларациях типов и не заметить проблему на локальном импорте.\n\nПоэтому `verify` заканчивается проверкой будущего пакета:\n\n```json\n{\n  \"verify\": \"npm run lint && npm run typecheck && npm run test:unit && npm run build && npm run test:smoke && npm run pack:check\",\n  \"pack:check\": \"npm pack --dry-run && publint && attw --pack . --profile node16\"\n}\n```\n\n`npm pack --dry-run` показывает состав будущего архива пакета. `publint` проверяет метаданные пакета и совместимость точек входа. `attw` смотрит, как опубликованный пакет виден TypeScript при разных вариантах разрешения модулей.\n\nПолучилась довольно понятная последовательность:\n\n```text\nsource\n  ↓\nbuild\n  ↓\ndist imports\n  ↓\npackage metadata \u002F types resolution\n```\n\nЗелёной сборки теперь недостаточно. Перед публикацией должен пройти весь путь до контракта пакета.\n\n## Та же команда в CI\n\nОтдельно дублировать эти проверки в GitHub Actions не хотелось. Workflow запускает тот же `npm run verify`, который можно прогнать локально:\n\n```yaml\nstrategy:\n  matrix:\n    node: [22, 24]\n\nsteps:\n  - uses: actions\u002Fcheckout@v4\n  - uses: actions\u002Fsetup-node@v4\n    with:\n      node-version: ${{ matrix.node }}\n      cache: npm\n  - run: npm ci\n  - run: npm run verify\n```\n\nТак у локальной разработки и CI нет двух похожих, но разных наборов требований. Если меняется проверка пакета, она меняется в одном скрипте, а workflow просто запускает его на поддерживаемых версиях Node.\n\n## Публикация без постоянного токена npm\n\nПубликацию тоже перенёс в GitHub Actions. Workflow запускается после GitHub Release, ещё раз выполняет `verify` и затем публикует пакет:\n\n```yaml\npermissions:\n  contents: read\n  id-token: write\n\nsteps:\n  - uses: actions\u002Fcheckout@v4\n  - uses: actions\u002Fsetup-node@v4\n    with:\n      node-version: 24\n      registry-url: https:\u002F\u002Fregistry.npmjs.org\n  - run: npm install -g npm@latest\n  - run: npm ci\n  - run: npm run verify\n  - run: npm publish --provenance --access public\n```\n\nДля доступа к npm используется Trusted Publishing через OIDC, поэтому долгоживущий `NPM_TOKEN` в секретах GitHub больше не нужен. `--provenance` добавляет к опубликованному пакету информацию о происхождении сборки.\n\nРелиз перестал быть просто командой после удачной сборки. Теперь перед `npm publish` отдельно проверяется то, что действительно увидит пользователь пакета: обе точки входа, декларации типов, метаданные и содержимое архива.\n\n## Материалы\n\n- [npm: Trusted publishing for npm packages](https:\u002F\u002Fdocs.npmjs.com\u002Ftrusted-publishers)\n- [publint](https:\u002F\u002Fpublint.dev\u002Fdocs\u002F)\n- [Are the types wrong?](https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002F@arethetypeswrong\u002Fcli)\n","poliruem-clicktone","2026-07-03","2026-09-29T13:33:49.801Z",[],{"id":51,"documentId":52,"title":53,"content":54,"slug":55,"author":10,"displayDate":56,"publishedAt":57,"tags":58},68,"zz71n3thhwzglwwguq7j23sf","Vue поверх DOM-контроллера, а не вместо него","После `2.0.0` у DialogLite уже есть достаточно самостоятельный DOM API. Можно передать готовый элемент или селектор, настроить закрытие, фокус и прокрутку, а затем явно уничтожить контроллер через `destroy()`.\n\nДля обычного JavaScript этого хватает. Но в рабочих проектах много Vue и Nuxt, и там вокруг такого API неизбежно появляется фреймворковая обвязка. DOM-элемент доступен только после монтирования, состояние открытия хочется видеть как реактивное значение, а при размонтировании компонента нужно снять обработчики и очистить таймеры.\n\nВ `2.1.0` эта обвязка переехала в сам пакет. Переписывать DialogLite как Vue-компонент при этом не хотелось: основной контроллер уже решает свою задачу без фреймворка.\n\nТеперь у пакета есть вторая точка входа:\n\n```ts\nimport { useDialogLite, DialogLiteRoot } from 'dialog-lite\u002Fvue';\n```\n\nОбычный импорт остаётся прежним:\n\n```ts\nimport { initDialogLite } from 'dialog-lite';\n```\n\nVue здесь отдельный слой поверх DOM-контроллера, а не новая основа библиотеки.\n\n## Отдельная точка входа вместо Vue в основном пакете\n\nVue API можно было экспортировать прямо из корневого `dialog-lite`. Пользователю не пришлось бы помнить про дополнительный путь импорта, но тогда Vue оказался бы частью контракта пакета даже для тех, кому нужен только DOM-контроллер.\n\nВ `2.1.0` поле `exports` разделено:\n\n```json\n{\n  \"exports\": {\n    \".\": {\n      \"types\": \".\u002Fdist\u002Findex.d.ts\",\n      \"import\": \".\u002Fdist\u002Findex.es.js\",\n      \"require\": \".\u002Fdist\u002Findex.cjs\"\n    },\n    \".\u002Fvue\": {\n      \"types\": \".\u002Fdist\u002Fvue.d.ts\",\n      \"import\": \".\u002Fdist\u002Fvue.es.js\",\n      \"require\": \".\u002Fdist\u002Fvue.cjs\"\n    }\n  }\n}\n```\n\nVue остаётся peer-зависимостью, а через `peerDependenciesMeta` помечена как необязательная:\n\n```json\n{\n  \"peerDependencies\": {\n    \"vue\": \"^3.3.0\"\n  },\n  \"peerDependenciesMeta\": {\n    \"vue\": {\n      \"optional\": true\n    }\n  }\n}\n```\n\nВ сборку Vue не включается и остаётся внешней зависимостью.\n\nДля обычного DOM API остаётся `dialog-lite`. Vue-интеграция подключается отдельно через `dialog-lite\u002Fvue`, а сам Vue уже приходит из приложения.\n\nМне нравится такая граница: основной пакет ничего не знает о `ref`, `v-model` и жизненном цикле Vue. Всё фреймворковое остаётся в адаптере.\n\n## Composable для своей разметки\n\nНе во всех проектах хочется, чтобы библиотека задавала структуру диалога. Часто разметка уже принадлежит приложению, особенно если внутри формы, сложные слоты или проектные компоненты.\n\nДля таких случаев появился `useDialogLite()`:\n\n```vue\n\u003Cscript setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useDialogLite } from 'dialog-lite\u002Fvue';\n\nconst dialogRef = ref\u003CHTMLElement | null>(null);\n\nconst { isOpen, open, close } = useDialogLite(dialogRef, {\n  closingBackdrop: true,\n  mainContent: null,\n});\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton type=\"button\" @click=\"open()\">\n    Open\n  \u003C\u002Fbutton>\n\n  \u003Cdiv\n    ref=\"dialogRef\"\n    class=\"dialog-lite dialog-lite--out\"\n    hidden\n    aria-hidden=\"true\"\n  >\n    \u003Cdiv class=\"dialog-lite__backdrop\">\u003C\u002Fdiv>\n    \u003Cdiv class=\"dialog-lite__container\">\n      \u003Cdiv class=\"dialog-lite__container-inner\">\n        \u003Cbutton type=\"button\" @click=\"close\">Close\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n```\n\nComposable принимает Vue `Ref` с элементом или функцию, которая этот элемент возвращает. Экземпляр `DialogLite` создаётся только после монтирования, когда DOM уже доступен.\n\nЖизненный цикл сводится к знакомой схеме:\n\n```ts\nonMounted(() => {\n  init();\n});\n\nonScopeDispose(() => {\n  destroy();\n});\n```\n\nЭто именно та обвязка, которую не хочется повторять в каждом компоненте. `init()` получает элемент из `ref` и передаёт его обычному `DialogLite` через параметр `dialog`, а `destroy()` запускает очистку DOM-контроллера.\n\nНового механизма открытия здесь нет. `open()` и `close()` делегируют работу DialogLite, а адаптер добавляет реактивное состояние:\n\n```ts\nconst isOpen = ref(false);\n```\n\nЧтобы состояние не расходилось с контроллером, composable использует колбэки основного API. После `open` значение становится `true`, после `close` — `false`; пользовательские `onOpen` и `onClose` продолжают вызываться дальше.\n\nТо есть второй жизненный цикл модального окна внутри Vue-слоя не появляется. Адаптер только синхронизирует состояние с уже существующим контроллером.\n\n## Компонент для стандартной обёртки\n\nComposable оставляет разметку приложению. Но если стандартная структура DialogLite устраивает, каждый раз писать корневой элемент, фон и контейнер тоже не хочется.\n\nДля этого появился `DialogLiteRoot`:\n\n```vue\n\u003Cscript setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { DialogLiteRoot } from 'dialog-lite\u002Fvue';\n\nconst isDialogOpen = ref(false);\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton type=\"button\" @click=\"isDialogOpen = true\">\n    Open\n  \u003C\u002Fbutton>\n\n  \u003CDialogLiteRoot\n    v-model=\"isDialogOpen\"\n    close-on-backdrop\n    :main-content=\"null\"\n  >\n    \u003Cp>Dialog content\u003C\u002Fp>\n  \u003C\u002FDialogLiteRoot>\n\u003C\u002Ftemplate>\n```\n\nКомпонент рендерит стандартную BEM-структуру, внутри использует тот же `useDialogLite()`, а внешнее состояние управляется через `v-model`.\n\nСинхронизация работает в обе стороны. Изменение `modelValue` вызывает `open()` или `close()`. Если контроллер сам закрывает окно по фону или Escape, компонент отправляет `update:modelValue`:\n\n```ts\nonClose: (detail) => {\n  emit('update:modelValue', false);\n  emit('close', detail);\n}\n```\n\nИначе легко получить ситуацию, когда окно уже закрыто, а `v-model` у родителя всё ещё `true`.\n\n`DialogLiteRoot` оставляет точки расширения через слоты. Можно заменить фон или кнопку закрытия, а слот по умолчанию получает методы `open`, `close` и текущее `isOpen`.\n\n## Nuxt требует аккуратнее обращаться с жизненным циклом\n\nДля Nuxt есть отдельная деталь: сам факт наличия Vue API ещё не означает, что DOM можно трогать во время SSR.\n\n`useDialogLite()` не создаёт контроллер при импорте модуля. Инициализация происходит в `onMounted()`, то есть уже на клиенте. Поэтому `dialog-lite\u002Fvue` можно импортировать в SSR-приложении без немедленного обращения к DOM.\n\nЭто не модуль Nuxt и не отдельная SSR-абстракция. Для страницы с SSR граница остаётся простой: DialogLite инициализируется на клиенте, а сам диалог при необходимости можно поместить в `\u003CClientOnly>`.\n\nПрятать браузерную природу библиотеки за дополнительной фреймворковой магией здесь не хочется. Vue-адаптер просто подключает DOM-контроллер в подходящий момент жизненного цикла.\n\n## Проверять теперь нужно две точки входа\n\nВторая точка входа добавила ещё одну поверхность, которую легко случайно сломать при сборке.\n\nТеперь в `dist` должны появиться отдельные файлы Vue-адаптера:\n\n```text\ndist\u002Findex.es.js\ndist\u002Findex.cjs\ndist\u002Findex.d.ts\ndist\u002Fvue.es.js\ndist\u002Fvue.cjs\ndist\u002Fvue.d.ts\ndist\u002Fdialog-lite.css\n```\n\nВместе с адаптером появились Vue-тесты и smoke-проверка собранного пакета.\n\nОдин тест монтирует компонент с `useDialogLite()`, открывает и закрывает окно, а после размонтирования проверяет очистку. Другой проходит через `DialogLiteRoot`: управление через `modelValue`, добавление класса оформления и закрытие по фону.\n\nSmoke-проверка работает уже с `dist`: проверяет наличие файлов Vue-адаптера и импортирует собранный `vue.es.js`, чтобы убедиться, что наружу действительно выходят `useDialogLite` и `DialogLiteRoot`.\n\nTypeScript-файл может без проблем компилироваться внутри репозитория, но этого мало, если пользователь потом не может импортировать его через обещанный путь.\n\n## Один контроллер, два способа интеграции\n\nВ `2.1.0` Vue не стал главным способом использования DialogLite. Основной пакет по-прежнему работает с DOM и доступен через `dialog-lite`, а интеграция с Vue живёт отдельно в `dialog-lite\u002Fvue`.\n\nПри этом можно выбрать уровень обвязки. `useDialogLite()` подходит, когда разметка принадлежит приложению. `DialogLiteRoot` — когда стандартная структура устраивает и хочется сократить шаблонный код.\n\nПолучился один контроллер диалога и два способа встроить его во Vue, без второй реализации той же логики.\n","vue-poverh-dom-kontrollera-a-ne-vmesto-nego","2026-06-08","2026-09-29T13:31:30.303Z",[],{"id":60,"documentId":61,"title":62,"content":63,"slug":64,"author":10,"displayDate":65,"publishedAt":66,"tags":67},79,"gnxwj8ybwrbrexf3hhmg3cwj","Один AudioContext для всех звуков","В HopperGame звук перестал быть разовым эффектом, который можно включить и забыть.\n\nОдин звук должен идти циклически, пока персонаж находится в определённом состоянии. Другой при повторном `play()` можно наложить поверх предыдущего. Третий нужно прервать или вообще не запускать повторно, пока он ещё звучит.\n\nЗдесь же снова проявилась особенность iOS. После возврата в Safari одного `AudioContext.state` оказалось недостаточно: контекст может сообщать `running`, хотя реального звука уже нет.\n\nС моделью, где каждый `ClickTone` сам владеет своим `AudioContext`, разбирать всё это по экземплярам стало неудобно.\n\n## Общую часть вынести в движок\n\nДо `2.0.0` у каждого звука был свой контекст и свой кеш декодированных файлов. Для простого `play()` это нормально, но разблокировка и восстановление после возврата на страницу относятся уже не к конкретному эффекту. Это состояние всей аудиосистемы страницы.\n\nПоэтому появился `SharedAudioEngine`:\n\n```ts\nexport class SharedAudioEngine {\n  #ctx: AudioContext | null = null;\n  #decodeCache = new Map\u003Cstring, Promise\u003CAudioBuffer>>();\n\n  \u002F\u002F ...\n}\n\nexport const engine = new SharedAudioEngine();\n```\n\nЭкземпляры ClickTone теперь обращаются к нему за общими вещами:\n\n```ts\nengine.prime();\n\nconst buffer = await engine.decode(url);\nconst ctx = engine.context();\n```\n\nКеш тоже стал общим. Если два звука используют один URL, загружать и декодировать один файл дважды смысла нет.\n\nПри этом настройки конкретного эффекта остались в `ClickTone`: `volume`, `mute`, `throttle`, `pitch` и собственный `GainNode`. Движок занимается только тем, что действительно должно жить на уровне страницы.\n\n## Одна разблокировка вместо обработчиков на каждом звуке\n\nДвижок устанавливает общий набор обработчиков пользовательских жестов и изменений состояния страницы:\n\n```ts\nprime(): void {\n  if (this.#primed || !hasDOM) return;\n\n  this.#primed = true;\n  this.#installGestureUnlock();\n  this.#installVisibilityHandler();\n}\n```\n\nПервый подходящий жест вызывает `unlock()`. Если контекст приостановлен, движок вызывает `resume()`. После успешной разблокировки дополнительно запускается почти бесшумный односэмпловый буфер, чтобы помочь Web Audio окончательно проснуться.\n\nГлавное здесь не сам трюк с буфером, а то, что вопрос разблокировки решается один раз для всей страницы. Каждому звуку больше не нужны свои обработчики касаний и собственное представление о том, готов ли Web Audio.\n\n## Если AudioContext только выглядит рабочим\n\nПосле `2.0.0` нашёлся неприятный кейс Safari\u002FiOS: после ухода из браузера и возврата обратно `AudioContext` может остаться в `state === 'running'`, но перестать реально воспроизводить звук.\n\nПоэтому в `2.1.0` проверяю ещё и `currentTime`. После возврата на страницу движок запоминает значение, ждёт `200ms` и смотрит, продвинулось ли аудиовремя:\n\n```ts\nconst start = ctx.currentTime;\n\nawait new Promise((resolve) =>\n  setTimeout(resolve, SharedAudioEngine.#ZOMBIE_PROBE_MS),\n);\n\nif (ctx.state === 'running' && ctx.currentTime \u003C= start) {\n  this.#needsHardRecovery = true;\n}\n```\n\nЕсли состояние говорит `running`, а `currentTime` стоит на месте, обычного `resume()` уже недостаточно.\n\nСразу пересоздавать контекст на `visibilitychange` я не стал. Полное восстановление откладывается до следующего пользовательского действия:\n\n```ts\nif (this.#needsHardRecovery) this.#recreate();\n```\n\nТак тяжёлый путь включается только после проверки и в момент, когда браузер с большей вероятностью разрешит снова поднять аудио.\n\n## После пересоздания нужно восстановить аудиограф\n\n`new AudioContext()` сам по себе проблему не заканчивает. `GainNode` и активные `AudioBufferSourceNode` были созданы в старом контексте и вместе с ним становятся бесполезны.\n\nПоэтому ClickTone подписывается на пересоздание контекста:\n\n```ts\nthis.#recreateUnsubscribe = engine.onRecreate(() =>\n  this.#restartActiveLoops(),\n);\n```\n\n`GainNode` пересоздаётся, если относится уже не к текущему контексту. Активные циклические воспроизведения собираются по URL и запускаются заново через новый движок.\n\nИменно на этом месте стало понятно, что циклическое воспроизведение нельзя считать тем же коротким звуком, только с `source.loop = true`. Если звук живёт дольше одного вызова, библиотеке приходится помнить о нём.\n\n## Хранить активное воспроизведение\n\nВ `2.1.0` экземпляр начал отслеживать созданные источники звука:\n\n```ts\ntype ActivePlayback = {\n  source: AudioBufferSourceNode;\n  url: string;\n  loop: boolean;\n  stopped: boolean;\n};\n\n#activePlaybacks = new Set\u003CActivePlayback>();\n```\n\nПри запуске источник добавляется в набор:\n\n```ts\nsource.buffer = buffer;\nsource.loop = loop;\n\nconst playback: ActivePlayback = {\n  source,\n  url,\n  loop,\n  stopped: false,\n};\n\nthis.#activePlaybacks.add(playback);\nsource.start(0);\n```\n\nПосле этого у `stop()` появляется конкретный объект, который можно завершить:\n\n```ts\nstop(): void {\n  this.#stopActivePlaybacks(true);\n}\n```\n\nДля HopperGame это как раз тот API, которого не хватало:\n\n```ts\nconst flying = new ClickTone({\n  src: '.\u002Fflying.mp3',\n  loop: true,\n  preload: true,\n});\n\nawait flying.play();\n\n\u002F\u002F состояние закончилось\nflying.stop();\n```\n\nДлительность эффекта здесь задаёт уже состояние игры, а не длина аудиофайла.\n\n## Что означает повторный play()\n\nДля короткого звука клика наложение обычно нормально. У игрового эффекта повторный вызов может означать совсем другое, поэтому в `2.1.0` это стало явной настройкой:\n\n```ts\ntype ReplayBehavior =\n  | 'overlap'\n  | 'interrupt'\n  | 'ignore-if-playing'\n  | 'restart';\n```\n\n`overlap` оставляет прежнее поведение и создаёт новое воспроизведение. `interrupt` останавливает активный и запускает новый. `ignore-if-playing` ничего не делает, пока звук уже идёт. `restart` обязательно начинает воспроизведение заново.\n\nДля `restart` обычный `throttle` пришлось обходить:\n\n```ts\nif (\n  !skipThrottle &&\n  replay !== 'restart' &&\n  now - this.#lastPlay \u003C this.#throttle\n) {\n  return;\n}\n```\n\nИначе явная команда перезапустить звук могла бы потеряться из-за ограничения, которое решает совсем другую задачу.\n\nПоведение при повторном `play()` можно задать на экземпляре:\n\n```ts\nconst terminal = new ClickTone({\n  src: '.\u002Fterminal.mp3',\n  replay: 'interrupt',\n});\n\nawait terminal.play();\n```\n\n## Что возвращает play() для loop\n\nДля обычного звука `play()` возвращает `Promise`, который завершается после `source.onended`. У циклического воспроизведения естественного конца может не быть вообще.\n\nПоэтому циклическое воспроизведение считается запущенным сразу после `source.start(0)`, а ожидание `onended` остаётся только для обычного эффекта:\n\n```ts\nsource.start(0);\nthis.#emit('play');\n\nif (!loop) await ended;\n```\n\nЗаодно `stop` получил отдельное событие. Естественное завершение даёт `end`, явное прерывание — `stop`.\n\nВ результате короткий UI-звук по-прежнему запускается обычным `play()`. Но если эффект связан с состоянием игры, его теперь можно зациклить, остановить и заранее определить, что должен означать повторный вызов. А восстановление Web Audio больше не размазано по экземплярам и живёт рядом с общим `AudioContext`.\n\n## Материалы\n\n- MDN, Web Audio best practices: \u003Chttps:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_Audio_API\u002FBest_practices>\n- MDN, `AudioContext.resume()`: \u003Chttps:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAudioContext\u002Fresume>\n- MDN, `BaseAudioContext.currentTime`: \u003Chttps:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FBaseAudioContext\u002FcurrentTime>\n- WebKit Bug 217606: \u003Chttps:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=217606>\n- WebKit Bug 263627: \u003Chttps:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=263627>\n- WebKit Bug 276687: \u003Chttps:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=276687>\n","odin-audio-context-dlya-vseh-zvukov","2026-06-03","2026-09-29T13:33:20.294Z",[],{"id":69,"documentId":70,"title":71,"content":72,"slug":73,"author":10,"displayDate":74,"publishedAt":75,"tags":76},75,"kcybzkzc77id357qven9oau2","Делаем API Iconly предсказуемым","В `2.0` в основном приводил в порядок внутреннее устройство iconly, сохраняя прежний способ использования. Для следующего мажорного релиза задача другая. Теперь я хочу пересобрать сам публичный контракт так, чтобы по нему было проще понять, что произошло во время инициализации, подменить отдельные части и нормально тестировать библиотеку.\n\nЗаодно это часть более общей работы по унификации моих библиотек. Мне нужна не просто работающая утилита с собственными привычками, а более одинаковый подход к API: явные результаты операций, небольшая публичная поверхность и заменяемые зависимости там, где это действительно полезно.\n\nВ `3.0.0` поменялось сразу несколько вещей. `new Iconly()` уступил место `createIconly()`, `init()` возвращает `Result`, хранилище становится отдельным интерфейсом, а монолитный исходник разбивается на ядро, работу с DOM, загрузку, ошибки и адаптеры хранилища.\n\n## Одного `Promise\u003Cvoid>` уже мало\n\nВ `2.0.1` вызов выглядит просто:\n\n```ts\nimport Iconly from 'iconly';\n\nconst iconly = new Iconly({\n  file: '.\u002Fsprite.svg',\n  version: '1.0',\n});\n\nawait iconly.init();\n```\n\nНо из самого результата вызова невозможно понять, загрузился ли спрайт. `init()` возвращает `Promise\u003Cvoid>`, а ошибки выполнения обрабатываются внутри библиотеки. Для небольшой утилиты этого достаточно, но такой контракт плохо масштабируется. Я хочу явно различать ошибку контейнера, загрузки, хранилища или разбора SVG и при этом не заставлять пользователя угадывать поведение по выводу в консоль.\n\nВ `3.0` результат становится частью API:\n\n```ts\nimport { createIconly } from 'iconly';\n\nconst iconLoader = createIconly({\n  file: '.\u002Fsprite.svg',\n  version: '1.0',\n});\n\nconst result = await iconLoader.init();\n\nif (!result.ok) {\n  console.error(result.error);\n}\n```\n\nСам тип очень небольшой:\n\n```ts\nexport type Result\u003CT> =\n  | { ok: true; value: T }\n  | { ok: false; error: IconlyError };\n```\n\nДля `init()` это фактически `Promise\u003CResult\u003Cvoid>>`. У вызывающего кода теперь есть два явных состояния, и TypeScript различает их по `ok`.\n\n## Ошибка тоже становится данными\n\nОдного `ok: false` мало, если дальше всё равно приходится разбирать текст сообщения. Поэтому у ошибки появляется собственная форма:\n\n```ts\nexport interface IconlyError {\n  code: IconlyErrorCode;\n  message: string;\n  cause?: unknown;\n}\n```\n\nВ текущем релизе коды описывают конкретные границы операции:\n\n```ts\ntype IconlyErrorCode =\n  | 'container_invalid'\n  | 'fetch_aborted'\n  | 'fetch_failed'\n  | 'indexeddb_not_supported'\n  | 'indexeddb_open_failed'\n  | 'indexeddb_request_failed'\n  | 'parse_error'\n  | 'storage_read_failed'\n  | 'storage_unavailable'\n  | 'storage_write_failed';\n```\n\nТеперь код потребителя может реагировать на категорию ошибки, а не на конкретную формулировку текста:\n\n```ts\nconst result = await iconLoader.init();\n\nif (!result.ok && result.error.code === 'fetch_aborted') {\n  \u002F\u002F запрос был отменён\n}\n```\n\nТот же `Result` проходит через внутренние части библиотеки. `fetchSvg()` возвращает `Result\u003Cstring>`, вставка SVG возвращает `Result\u003Cvoid>`, адаптеры хранилища возвращают `Result` из `get()` и `set()`.\n\nЭто позволяет ядру собирать одну последовательность операций без смеси исключений, `null`, логических флагов и скрытого логирования.\n\nВ README контракт `init()` сформулирован прямо: штатные ошибки операции не выбрасываются исключением, а возвращаются через `Result`, который нужно проверить.\n\n## Логирование больше не единственный канал ошибок\n\nВ предыдущей версии отладочный вывод был частью внутреннего поведения класса. Теперь диагностику тоже можно явно подключить к приложению:\n\n```ts\nconst iconLoader = createIconly({\n  onError: (error) => reportError(error),\n  onDebug: (...messages) => debugLog(...messages),\n  logger: {\n    debug: (...messages) => console.debug(...messages),\n    error: (...messages) => console.error(...messages),\n  },\n});\n```\n\nПри этом колбэки не заменяют `Result`. Ошибка передаётся в `onError` и `logger.error`, но `init()` всё равно возвращает тот же объект ошибки вызывающему коду.\n\n`debug` управляет только отладочными сообщениями. Ошибки остаются отдельным каналом и не исчезают из-за `debug: false`.\n\nДля меня это важное разделение. Результат операции нужен программе. `logger` и колбэки нужны для диагностики вокруг неё. Это не одна и та же задача.\n\n## Фабричная функция вместо публичного класса\n\nВместе с новым контрактом ошибок я убираю публичный конструктор:\n\n```ts\nconst iconLoader = createIconly(config);\n```\n\nФабричная функция возвращает небольшой объект:\n\n```ts\nexport interface IconlyInstance {\n  init: () => Promise\u003CResult\u003Cvoid>>;\n  abort: () => void;\n}\n```\n\nМне здесь важна не сама замена синтаксиса `new` на функцию. Публичный класс заставляет считать устройство класса частью API. В новой версии потребителю важны только доступные операции экземпляра.\n\nЭто лучше укладывается в стиль публичных API, к которому я привожу свои библиотеки: меньше обязательной формы реализации и больше явных контрактов через типы.\n\nВторой метод, `abort()`, нужен для активного `fetch`. Внутри `createIconly()` хранится `AbortController`, а отменённый запрос превращается в обычный результат с кодом `fetch_aborted`:\n\n```ts\nconst iconLoader = createIconly({ file: '.\u002Fsprite.svg' });\n\nconst pending = iconLoader.init();\niconLoader.abort();\n\nconst result = await pending;\n```\n\nТо есть отмена тоже получает место в том же контракте ошибок, а не отдельную случайную ветку поведения.\n\n## IndexedDB больше не должен быть самим ядром\n\nВ ранних версиях iconly хранилище и основная логика были почти одним целым. С `3.0` я хочу оставить IndexedDB вариантом по умолчанию, но перестать делать его обязательной формой внутреннего устройства.\n\nДля этого появился публичный интерфейс:\n\n```ts\nexport interface IconStorage {\n  get(version: string): Promise\u003CResult\u003CIconRecord | undefined>>;\n  set(record: IconRecord): Promise\u003CResult\u003Cvoid>>;\n}\n```\n\nА опция `storage` принимает несколько стратегий:\n\n```ts\nexport type StorageStrategy =\n  | 'indexeddb'\n  | 'memory'\n  | 'session'\n  | IconStorage;\n```\n\nВстроенных вариантов три:\n\n```ts\ncreateIconly({ storage: 'indexeddb' });\ncreateIconly({ storage: 'memory' });\ncreateIconly({ storage: 'session' });\n```\n\nЧетвёртый вариант — собственная реализация `IconStorage`.\n\nЗдесь для меня особенно важна тестируемость. Ядро теперь общается с `get()` и `set()`, а не знает детали каждой реализации. Для обычного теста можно выбрать хранилище в памяти и не поднимать IndexedDB вообще. Там, где нужно проверить именно IndexedDB, можно тестировать эту стратегию отдельно в контролируемой среде.\n\n## Хранилище заодно исправляет порядок работы кэша\n\nДо `3.0` IndexedDB уже использовался, но сама последовательность была неидеальной: iconly сначала выполнял `fetch()`, а потом читал сохранённую запись. Получалось, что кэш не мог убрать сетевой запрос.\n\nПосле разделения хранилища порядок становится естественнее:\n\n```ts\nconst cacheResult = await storage.get(resolved.version);\nlet data = cacheResult.value?.data;\n\nif (!data) {\n  const fetchResult = await fetchSvg(resolved.file, controller.signal);\n  data = fetchResult.value;\n\n  await storage.set({\n    version: resolved.version,\n    data,\n  });\n}\n```\n\nСначала читается хранилище. Только если записи нет, выполняется `fetch`.\n\nЭто не просто архитектурная перестановка. Поведение кэша реально меняется: повторная инициализация с той же версией может обойтись без сети.\n\nЗакрепил это тестом. Первый экземпляр загружает `\u002Fsprite.svg` и записывает данные в IndexedDB. Второй использует ту же базу и ту же версию. Оба `init()` завершаются успешно, но подменённый `fetch` должен остаться вызванным только один раз.\n\n```ts\nexpect(firstResult.ok).toBe(true);\nexpect(fetchMock).toHaveBeenCalledTimes(1);\n\nconst secondResult = await second.init();\n\nexpect(secondResult.ok).toBe(true);\nexpect(fetchMock).toHaveBeenCalledTimes(1);\n```\n\nДля предыдущей версии я не мог бы честно описать IndexedDB как способ избежать повторного сетевого запроса. Начиная с `3.0`, это уже соответствует коду и тесту.\n\n## Тесты становятся частью самой архитектуры\n\nВ `3.0` у пакета появляется набор тестов на Vitest, `jsdom` и `fake-indexeddb`.\n\nПростой DOM-сценарий можно проверить через хранилище в памяти:\n\n```ts\nconst iconly = createIconly({\n  storage: 'memory',\n  file: '\u002Fsprite.svg',\n  version: '1.0',\n  container,\n});\n\nconst result = await iconly.init();\n\nexpect(result.ok).toBe(true);\nexpect(container.querySelector('[data-iconly=\"iconset\"] svg')).not.toBeNull();\n```\n\nДля IndexedDB отдельный тест использует `fake-indexeddb` и проверяет чтение из кэша.\n\nИменно здесь понятно, зачем мне понадобилось разрезать библиотеку по границам. Это не абстракции ради количества файлов. Возможность выбрать хранилище в памяти позволяет тестировать основной сценарий без IndexedDB, а контракт `Result` позволяет проверять исход операции напрямую.\n\nВ `package.json` заодно появилась общая команда проверки:\n\n```json\n{\n  \"verify\": \"yarn lint && yarn typecheck && yarn test\"\n}\n```\n\n## Превращение одного файла в несколько понятных границ\n\nДо этого основная реализация жила в `src\u002Findex.ts`. В `3.0` структура становится такой:\n\n```text\nsrc\u002F\n  core.ts\n  dom.ts\n  errors.ts\n  fetcher.ts\n  index.ts\n  result.ts\n  storage\u002F\n    index.ts\n    indexeddb.ts\n    memory.ts\n    session.ts\n  types.ts\n```\n\n`index.ts` теперь в основном задаёт публичные экспорты. `core.ts` оркестрирует процесс. Вставка в DOM, загрузка, вспомогательные функции `Result` и разные реализации хранилища находятся отдельно.\n\nСамо количество файлов ничего не гарантирует. Для меня ценность в другом: эти границы совпадают с контрактами, которые появились в коде. Хранилище действительно можно заменить. Загрузка и DOM действительно возвращают тот же `Result`. Публичные типы действительно экспортируются из одной точки.\n\nТакую структуру проще проверять и проще менять.\n\n## Мажорный релиз всё равно требует миграции\n\nПредсказуемый API не означает полностью совместимый API. В `3.0` есть несколько намеренных несовместимых изменений.\n\nГлавный — конструктор заменён фабричной функцией, и результат `init()` теперь нужно проверять:\n\n```ts\n\u002F\u002F 2.x\nconst iconly = new Iconly(options);\nawait iconly.init();\n\n\u002F\u002F 3.x\nconst iconly = createIconly(options);\nconst result = await iconly.init();\n\nif (!result.ok) {\n  \u002F\u002F обработать result.error\n}\n```\n\nПоменялся и DOM-якорь. Вместо глобального `#iconset` обёртка создаётся внутри выбранного контейнера:\n\n```html\n\u003Cdiv data-iconly=\"iconset\" aria-hidden=\"true\">...\u003C\u002Fdiv>\n```\n\nЭто тоже часть нового контракта. Внутренний селектор больше не опирается на глобальный `id`, а обёртка явно помечена как служебная и скрыта от дерева доступности.\n","delaem-api-iconly-predskazuemym","2026-01-25","2026-09-29T13:32:38.766Z",[],{"id":78,"documentId":79,"title":80,"content":81,"slug":82,"author":10,"displayDate":83,"publishedAt":84,"tags":85},77,"x43svzw5tuasmmygpugo9ijk","Жёсткий DOM-контракт начал мешать","В версии `1.5.1` у DialogLite был удобный, но довольно жёсткий договор с разметкой. Контроллер искал первый `.dialog-lite`, знал про `#main-content`, `.dialog-lite-close-button` и `.dialog-lite__backdrop`, а задержки открытия и закрытия были зафиксированы в коде на `500ms`.\n\nДля первой версии этого хватало. Но чем больше сценариев я пытался укладывать в тот же контроллер, тем заметнее становилось, что часть решений на самом деле принадлежит не библиотеке, а конкретной странице.\n\nВ `2.0.0` я решил провести эту границу заново. DialogLite по-прежнему не создаёт UI и не требует своей системы компонентов, но теперь может работать не только с одной заранее известной DOM-структурой.\n\nБазовая инициализация выглядит так:\n\n```ts\nimport { initDialogLite } from 'dialog-lite';\n\nconst dialog = initDialogLite({\n  dialog: '#settings-dialog',\n  mainContent: '#page',\n  closingButton: true,\n  closingBackdrop: true,\n  hideDelayMs: 300,\n  debounceMs: 300,\n});\n```\n\nКорневой элемент больше не нужно переименовывать в `.dialog-lite`, а основную страницу — подгонять под `#main-content`. Дефолты остались для простого старта, но перестали быть обязательной частью интеграции.\n\n## Передавать элемент или селектор\n\nВ старом контроллере поиск DOM был зашит прямо в `init()`:\n\n```ts\nthis.dialogEl = document.querySelector\u003CHTMLDivElement>('.dialog-lite');\nthis.mainContentEl = document.getElementById('main-content');\n```\n\nВ `2.0.0` корневой элемент диалога и основной контент принимают либо CSS-селектор, либо готовый `HTMLElement`:\n\n```ts\nexport type DialogLiteOptions = {\n  dialog?: HTMLElement | string;\n  mainContent?: HTMLElement | string | null;\n  closeButtonSelector?: string;\n  backdropSelector?: string;\n  \u002F\u002F ...\n};\n```\n\nРазрешение этих значений сведено к одной функции:\n\n```ts\nprivate resolveHTMLElement(\n  input: HTMLElement | string | null | undefined,\n): HTMLElement | null {\n  if (input == null) return null;\n  if (typeof input === 'string') {\n    return document.querySelector\u003CHTMLElement>(input);\n  }\n\n  return input;\n}\n```\n\nТак не приходится выбирать один способ интеграции. На простой странице удобнее передать селектор. Если приложение уже хранит ссылки на DOM-элементы, повторно искать их через `document.querySelector()` не нужно.\n\nКнопка закрытия и фон остались селекторами, но ищутся внутри конкретного корневого элемента диалога. Их имена тоже можно заменить:\n\n```ts\nconst dialog = initDialogLite({\n  dialog: dialogElement,\n  closeButtonSelector: '[data-dialog-close]',\n  backdropSelector: '[data-dialog-backdrop]',\n  closingButton: true,\n  closingBackdrop: true,\n});\n```\n\nКонтроллер всё ещё знает, какие роли ему нужны, но конкретные имена классов больше не навязывает.\n\n## Время анимации тоже часть интеграции\n\nРаньше `500ms` одновременно ограничивали повторные вызовы `open()` и `close()` и задавали задержку перед окончательным скрытием окна. Это было связано с базовыми стилями пакета, но в коде выглядело как универсальная константа.\n\nТеперь оба значения задаются отдельно:\n\n```ts\nthis.options = {\n  \u002F\u002F ...\n  debounceMs: options.debounceMs ?? 500,\n  hideDelayMs: options.hideDelayMs ?? 500,\n};\n```\n\nКонтроллер всё ещё использует простую временную модель и не вычисляет реальную длительность произвольной CSS-анимации. Но теперь приложение может согласовать JavaScript со своим CSS-переходом, не меняя исходники библиотеки.\n\nЗаодно я отказался от переключения `display: none` через `style.display`. В `2.0.0` состояние видимости выражается через стандартный атрибут `hidden`:\n\n```ts\npublic open({ stylingClass = '' }: OpenOptions = {}): void {\n  \u002F\u002F ...\n  this.dialogEl.hidden = false;\n  void this.dialogEl.offsetWidth;\n  \u002F\u002F ...\n}\n\npublic close(): void {\n  \u002F\u002F ...\n  this.hideTimeout = window.setTimeout(() => {\n    if (this.dialogEl) {\n      this.dialogEl.hidden = true;\n    }\n  }, this.options.hideDelayMs);\n}\n```\n\nНачальная разметка теперь может сразу описывать закрытое состояние:\n\n```html\n\u003Cdiv\n  id=\"settings-dialog\"\n  class=\"dialog-lite dialog-lite--out\"\n  hidden\n  aria-hidden=\"true\"\n>\n  \u003C!-- content -->\n\u003C\u002Fdiv>\n```\n\n`hidden` отвечает за видимость элемента, а классы `--in` и `--out` остаются за визуальными фазами.\n\n## У инициализации появился обратный путь\n\nВ первой версии `init()` добавлял обработчики, после чего экземпляр предполагалось просто использовать дальше. Для локального скрипта этого достаточно, но переиспользуемому контроллеру нужен и обратный путь.\n\nПоэтому появился `destroy()`:\n\n```ts\npublic destroy(): void {\n  this.abortController?.abort();\n  this.abortController = null;\n  this.clearTimers();\n  this.unlockScroll();\n}\n```\n\nВсе обработчики, которые создаёт `init()`, получают один `AbortSignal`:\n\n```ts\nthis.abortController = new AbortController();\nconst signal = this.abortController.signal;\n\ndocument.addEventListener(\n  'keydown',\n  (event: KeyboardEvent) => {\n    if (event.key === 'Escape' && this.isOpen) {\n      this.close();\n    }\n  },\n  { signal },\n);\n```\n\nТа же схема используется для кнопки закрытия, фона и обработки клавиатуры внутри окна. Очистка сводится к `abort()`, без отдельного `removeEventListener()` для каждого обработчика.\n\n`init()` сначала вызывает `destroy()`, затем заново находит DOM-элементы и подключает обработчики:\n\n```ts\npublic init(): void {\n  this.destroy();\n  this.resolveElementsOrThrow();\n\n  this.abortController = new AbortController();\n  \u002F\u002F attach listeners\n}\n```\n\nТак повторная инициализация не накапливает обработчики.\n\nДля обычного сценария появилась вспомогательная функция `initDialogLite()`, которая создаёт экземпляр и сразу вызывает `init()`:\n\n```ts\nconst dialog = initDialogLite({\n  closingButton: true,\n  closingBackdrop: true,\n});\n```\n\nКлассический вариант остался:\n\n```ts\nconst dialog = new DialogLite({\n  closingButton: true,\n  closingBackdrop: true,\n});\n\ndialog.init();\n```\n\nЯвный `destroy()` никуда не исчезает, просто для частого сценария есть короткий путь.\n\n## Прокрутка и фокус больше не побочные детали\n\nОткрытое модальное окно обычно должно временно остановить прокрутку страницы. Раньше DialogLite этим не занимался, и блокировку прокрутки приходилось прикручивать снаружи.\n\nВ `2.0.0` он включён по умолчанию, но остаётся опцией:\n\n```ts\nconst dialog = initDialogLite({\n  lockScroll: true,\n});\n```\n\nПри открытии контроллер сохраняет значения `overflow` и `padding-right` из `body.style`, вычисляет ширину полосы прокрутки и при необходимости компенсирует её через `padding`. После закрытия исходные значения возвращаются.\n\n```ts\nconst scrollbarWidth =\n  window.innerWidth - document.documentElement.clientWidth;\n\nif (scrollbarWidth > 0) {\n  const currentPadding = Number.parseFloat(\n    getComputedStyle(body).paddingRight || '0',\n  );\n\n  body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;\n}\n\nbody.style.overflow = 'hidden';\n```\n\nКомпенсация нужна, чтобы после исчезновения полосы прокрутки страница не сдвигалась по горизонтали.\n\nПоведение фокуса тоже стало настраиваемым. Можно указать `focusOnOpenSelector`, включить или отключить `trapFocus`, изменить `role` и управление `aria-modal`.\n\nПолноценной абстракции доступности из этого ещё не получается. Но поведение клавиатуры и ARIA теперь задаётся явно, а не остаётся набором предположений внутри контроллера.\n\n## События вместо дополнительной связанности\n\nПриложению иногда нужно отреагировать на открытие или закрытие диалога. Добавлять ради каждого такого случая новый колбэк в конструктор не хотелось.\n\nВ `2.0.0` DialogLite отправляет DOM-события на самом элементе диалога:\n\n```ts\nthis.dialogEl.dispatchEvent(\n  new CustomEvent('dialog-lite:open', {\n    detail: { stylingClass },\n  }),\n);\n```\n\nИ при закрытии:\n\n```ts\nthis.dialogEl.dispatchEvent(\n  new CustomEvent('dialog-lite:close', {\n    detail: {},\n  }),\n);\n```\n\nИх можно отключить через `emitEvents: false`, а при включённом поведении приложение подписывается обычным DOM API:\n\n```ts\ndialogElement.addEventListener('dialog-lite:open', () => {\n  \u002F\u002F project-specific reaction\n});\n```\n\nТак DialogLite сообщает о событии, но не обрастает логикой конкретного приложения.\n\n## CSS можно импортировать или инжектировать\n\nДо этого пакет ожидал отдельный импорт собранного CSS. Такой вариант остался, только у файла стилей появился отдельный экспорт:\n\n```ts\nimport { initDialogLite } from 'dialog-lite';\nimport 'dialog-lite\u002Fdialog-lite.css';\n\nconst dialog = initDialogLite({\n  injectCss: false,\n});\n```\n\nНо `initDialogLite()` умеет и сам добавить базовые стили. По умолчанию `injectCss` включён:\n\n```ts\nexport function initDialogLite(options = {}): DialogLiteInstance {\n  const {\n    injectCss = true,\n    cssText,\n    cssTarget,\n    ...dialogOptions\n  } = options;\n\n  if (injectCss) {\n    injectDialogLiteCss({ cssText, target: cssTarget });\n  }\n\n  const instance = new DialogLite(dialogOptions);\n  instance.init();\n\n  return instance;\n}\n```\n\nСам CSS доступен и как строка `dialogLiteCssText`. `injectDialogLiteCss()` принимает `Document | ShadowRoot`, поэтому базовые стили можно положить и внутрь Shadow DOM:\n\n```ts\ninjectDialogLiteCss({\n  target: shadowRoot,\n});\n```\n\nShadow DOM здесь не становится отдельным направлением библиотеки. Просто у небольшого базового CSS теперь нет единственного способа подключения.\n\nПосле этой переработки DialogLite всё ещё остаётся DOM-контроллером. Он не рендерит содержимое, не вводит дерево компонентов и не управляет бизнес-логикой окна.\n\nНо дефолты теперь действительно работают как дефолты, а не как скрытые требования. Старую схему можно оставить почти без изменений или передать свои элементы, селекторы, тайминги и часть поведения модального окна через настройки.\n","zhyostkij-dom-kontrakt-nachal-meshat","2025-12-16","2026-09-29T13:32:57.900Z",[],{"id":87,"documentId":88,"title":89,"content":90,"slug":91,"author":10,"displayDate":92,"publishedAt":93,"tags":94},74,"cvonqqvil2tn4fmgaiabss76","Две плавные шкалы вместо корня 10px","Выпустил `typographics@3.0.0`. Перед стабильным релизом обкатал новую мажорную версию в реальных проектах. Главное изменение оказалось не в новой текстовой роли и не в ещё одном CSS-свойстве. Убрал из библиотеки старое правило:\n\n```scss\nhtml {\n  font-size: 10px;\n}\n```\n\nЭтот приём много лет жил в моих проектах ради простой арифметики: `1.6rem` легко читать как `16px`. Для собственного проекта это удобно. Для переиспользуемой библиотеки уже нет  — `typographics` менял корневой размер документа и начинал спорить с CSS проектов.\n\nС 3.0 решил больше не считать корневой размер частью внутренней математики. Библиотека должна работать поверх размера документа, который задаёт приложение.\n\n## Корневой размер больше не часть формулы\n\nДо этого плавная шкала была завязана на `10px` сразу в нескольких местах. Даже границы `600px` и `1440px` приходилось переводить в `rem` через деление на 10:\n\n```scss\n--t-font-scale-min-width-rem:\n  calc((var(--t-font-scale-min-width, 600) \u002F 10) * 1rem);\n\nhtml {\n  font-size: 10px;\n}\n```\n\nУдобная локальная договорённость незаметно стала частью контракта библиотеки. Если проект использовал обычный корневой размер браузера или задавал свой, `typographics` всё равно приходил со своими `10px`.\n\nВ 3.0 корневой размер снова обычный:\n\n```scss\n@layer reset {\n  html {\n    font-size: 100%;\n    text-size-adjust: 100%;\n    box-sizing: border-box;\n  }\n}\n```\n\nТеперь `rem` снова означает размер относительно корня, который контролирует сам проект. Для расчёта плавной шкалы больше не нужно считать, что `1rem` равен `10px`.\n\nОт этой точки пришлось пересобрать и саму модель размеров.\n\n## Одного clamp() уже мало\n\nРаньше у пакета была одна база `--t-font-size-clamp`. Крупные роли росли относительно неё через `em`, а часть ролей основного текста оставалась фиксированной в `rem`. После отказа от корня `10px` решил сделать зависимость явной: у основного текста и у заголовков теперь свои плавные базы.\n\n```scss\n:root {\n  --t-body-font-size-min-scale: 0.875;\n  --t-body-font-size-max-scale: 1.125;\n  --t-body-font-size-min:\n    calc(var(--t-body-font-size-min-scale) * 1rem);\n  --t-body-font-size-max:\n    calc(var(--t-body-font-size-max-scale) * 1rem);\n  --t-body-font-size-clamp: clamp(\u002F* ... *\u002F);\n\n  --t-heading-font-size-min-scale: 1;\n  --t-heading-font-size-max-scale: 1.25;\n  --t-heading-font-size-min:\n    calc(var(--t-heading-font-size-min-scale) * 1rem);\n  --t-heading-font-size-max:\n    calc(var(--t-heading-font-size-max-scale) * 1rem);\n  --t-heading-font-size-clamp: clamp(\u002F* ... *\u002F);\n}\n```\n\nПричина разделения практическая. Заголовкам обычно нужна большая визуальная амплитуда изменения, чем абзацам. Если держать всё на одном `clamp()`, настройка одной части шкалы неизбежно тянет за собой другую.\n\nПри стандартном корневом размере `16px` текущие значения дают такую базу:\n\n| ширина | база текста | база заголовков |\n|---:|---:|---:|\n| `600px` | `14px` | `16px` |\n| `1020px` | `16px` | `18px` |\n| `1440px` | `18px` | `20px` |\n\nСами роли теперь не хранят готовый размер. Они задают коэффициент:\n\n```scss\n--t-display-large: 5.7;\n--t-headline-large: 3.2;\n--t-title-medium: 2;\n```\n\nМиксин заголовка умножает коэффициент на базу заголовков:\n\n```scss\nfont-size: calc(\n  var(--t-heading-font-size-clamp) * #{$k}\n);\n```\n\nДля абзацев и списков используется база основного текста:\n\n```scss\nfont-size: calc(\n  var(--t-body-font-size-clamp) * #{$k}\n);\n```\n\nНапример, `h2` в текущей раскладке соответствует `headline-large` с коэффициентом `3.2`. На `600px` это `16px × 3.2 = 51.2px`, на `1440px` — `20px × 3.2 = 64px`. Обычный абзац за тот же диапазон меняется с `14px` до `18px`. У заголовка получается заметно большая абсолютная амплитуда, хотя обе шкалы используют одни и те же границы.\n\nВажно, что это две независимые настройки. Если в конкретном проекте заголовки должны расти сильнее или слабее, для этого больше не нужно менять поведение основного текста.\n\n## Семантическая разметка без тяжёлых селекторов\n\nПараллельно с новой шкалой пересобрал способ, которым typographics входит в CSS проекта. Пакет всё чаще используется как базовые стили документа, поэтому странно требовать класс для каждого обычного `h2` или `p`. В 3.0 роли получают и нативные элементы:\n\n```scss\n:where(h1, .display-large) {\n  @include typography-heading(var(--t-display-large));\n}\n\n:where(h2, .headline-large) {\n  @include typography-heading(var(--t-headline-large));\n}\n\n:where(p, .body-medium) {\n  @include typography-paragraph(1em, 1.45, 400);\n}\n```\n\nМожно написать обычную статью с `h1`, `h2` и `p` и сразу получить базовую типографику. Классы ролей при этом никуда не исчезают, когда семантика и визуальная роль не совпадают.\n\nВторая половина решения — `:where()`. У него нулевая специфичность, поэтому семантическое правило по умолчанию не превращается в селектор, который потом приходится перебивать всё более длинной конструкцией.\n\nПо той же причине весь CSS теперь разложен по слоям каскада:\n\n```scss\n@layer reset, tokens, typography, components, utilities;\n```\n\nСейчас реально заполнены `tokens`, `reset` и `typography`. CSS проекта вне слоёв имеет приоритет над обычными декларациями внутри `@layer`. Получается нужное для библиотечного CSS поведение: typographics задаёт основу, но не пытается выиграть каскад у приложения любой ценой.\n\n`@layer` и `:where()` решают разные задачи. Слои управляют крупным порядком каскада, а `:where()` не раздувает специфичность конкретных семантических правил.\n\n## Обкатка перед релизом\n\nПеред `3.0.0` обкатал `3.0.0-dev.0` и `3.0.0-dev.1` на реальных проектах.\n\nВ финальном варианте оставил старые границы `600px` и `1440px`, но сама система вокруг них теперь другая. Корневой размер принадлежит проекту. Основной текст и заголовки масштабируются независимо. Семантические элементы получают базовые значения, которые не должны мешать проекту их переопределять.\n\nБиблиотеке больше не нужно владеть корнем документа, чтобы удобно считать типографику.\n\n## Материалы\n\n- [CSS Values and Units Level 4](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-values-4\u002F)\n- [CSS Cascading and Inheritance Level 5: cascade layers](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-cascade-5\u002F)\n- [Selectors Level 4: :where()](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fselectors-4\u002F)\n","dve-plavnye-shkaly-vmesto-kornya-10px","2025-11-04","2026-09-29T13:32:29.209Z",[],{"id":96,"documentId":97,"title":98,"content":99,"slug":100,"author":10,"displayDate":101,"publishedAt":102,"tags":103},72,"pvwfuo4kzfsvxwf7rz8tccbj","Вертикальный ритм на lh в typographics","Выпустил `typographics@2.4.7`. За последние две недели пакет заметно разросся вокруг довольно простой проблемы: на реальной странице одной шкалы заголовков и классов основного текста оказалось мало. В тексте появляются списки, встроенный `code`, большие блоки кода, формы. Все они должны не просто иметь подходящий размер шрифта, но и нормально складываться друг с другом по вертикали.\n\nДо этого в основном приводил в порядок сам пакет. В `2.0.0` заменил Parcel на Vite, ограничил публикацию готовой папкой `dist` и привёл CSS-переменные к префиксу `--t-*`. Следующая задача пришла уже из использования `typographics` на реальных страницах: в моём блоге, у друзей которые использовали пакет, и в рабочих проектах.\n\n## Когда одной шкалы уже мало\n\nВ `2.2.0` я начал с маленького правила для абзацев. Последнему абзацу в группе обычно не нужен нижний отступ, поэтому в paragraph mixin появилось:\n\n```scss\n&:last-of-type {\n  margin-block-end: 0;\n}\n```\n\nК `2.3.0` стало понятно, что этим документ не закрывается. Добавил размеры и интервалы для списков, стили для встроенного `code` и `pre`, наследование шрифта для `input`, `button`, `select` и `textarea`. Заодно появился общий базовый шаг:\n\n```scss\n:root {\n  --t-baseline: 0.8rem;\n  --t-half-baseline: calc(var(--t-baseline) \u002F 2);\n}\n\n@mixin typography-paragraph($paragraph-font-size, $paragraph-line-height) {\n  font-size: $paragraph-font-size;\n  line-height: $paragraph-line-height;\n  margin-bottom: var(--t-half-baseline);\n}\n```\n\nТак уже можно собирать обычную статью из одних и тех же примитивов, не настраивая каждый список и блок кода отдельно. Но общий шаг оставался фиксированной длиной. Для разных текстовых ролей с разным `font-size` и `line-height` хотелось, чтобы интервалы следовали самой строке текста.\n\n## Отступ как доля строки\n\nВ `2.4.0` я перевёл основные вертикальные интервалы на единицу `lh`. Она равна вычисленному `line-height` текущего элемента. Поэтому `0.5lh` — это половина его строки, а `0.75lh` — три четверти.\n\nДля заголовков получилось так:\n\n```scss\n@mixin typography-heading($heading-font-size) {\n  font-size: $heading-font-size;\n  line-height: var(--t-line-height-heading, 1.3);\n  margin-top: 1.25lh;\n  margin-bottom: 0.5lh;\n  max-inline-size: 50ch;\n  text-wrap: balance;\n}\n```\n\nДля основного текста:\n\n```scss\n@mixin typography-paragraph(\n  $paragraph-font-size,\n  $paragraph-line-height,\n  $paragraph-font-weight\n) {\n  font-size: $paragraph-font-size;\n  line-height: $paragraph-line-height;\n  font-weight: $paragraph-font-weight;\n  margin-bottom: 0.75lh;\n}\n```\n\nОдновременно сделал `line-height` текстовых ролей безразмерным. Например:\n\n```scss\n.body {\n  &-large  { @include typography-paragraph(1.6rem, 1.5, 400); }\n  &-medium { @include typography-paragraph(1.4rem, 1.45, 400); }\n  &-small  { @include typography-paragraph(1.2rem, 1.35, 400); }\n}\n```\n\nПри корневых `10px` у `.body-large` размер шрифта равен `16px`, а `line-height: 1.5` даёт строку `24px`. Значит, `margin-bottom: 0.75lh` превращается в `18px`.\n\nУ `.body-small` шрифт уже `12px`, `line-height: 1.35` даёт `16.2px`, а тот же `0.75lh` — `12.15px`. Правило одно, но интервал естественно меняется вместе с текстовой ролью.\n\nТо же самое работает для списков:\n\n```scss\n@mixin typography-list($font-size, $line-height, $margin-block) {\n  font-size: $font-size;\n  line-height: $line-height;\n  padding-inline-start: 2.5rem;\n  margin-block: $margin-block;\n\n  li { margin-block: 0.25lh; }\n}\n```\n\nЗадача не в \"идеальной\" базовой сетке на весь документ. Хотел связать расстояние между элементами с их фактическим межстрочным интервалом и за счёт этого получить более цельный вертикальный ритм при разных размерах текста.\n\n## last-of-type оказался слишком буквальным\n\nПосле унификации миксинов быстро нашёлся край в правиле для последнего элемента. В `2.4.2` я применил его и к headings, и к paragraphs:\n\n```scss\n&:last-of-type {\n  margin-block-end: 0;\n}\n```\n\nПроблема в том, что единственный элемент своего типа одновременно является и последним. Если в небольшом блоке находится только один абзац, селектор тоже срабатывает и убирает отступ, хотя следующий внешний элемент всё ещё может в нём нуждаться.\n\nВ `2.4.3` вынес reset в отдельный mixin и исключил этот случай:\n\n```scss\n@mixin reset-last-margin() {\n  &:last-of-type:not(:only-of-type) {\n    margin-block-end: 0;\n  }\n}\n```\n\nПолучилось чуть длиннее, зато правило теперь описывает нужный случай точнее: убрать хвост у последнего элемента своего типа, только если элементов этого типа в контейнере больше одного.\n\n## Отступ внутри горизонтальной прокрутки\n\nСледующая мелочь проявилась у длинных блоков кода. Изначально `pre` сам отвечал и за `overflow-x: auto`, и за внутренний отступ:\n\n```scss\npre {\n  padding: var(--t-code-block-padding, 1.2rem 2rem);\n  overflow-x: auto;\n  white-space: pre;\n}\n```\n\nПри горизонтальной прокрутке справа после последнего символа нужен такой же внутренний воздух, как слева. Поэтому в `2.4.4` отступ переехал на вложенный `code`, то есть внутрь прокручиваемого содержимого:\n\n```scss\npre {\n  overflow-x: auto;\n\n  code {\n    padding: var(--t-code-block-padding, 1.2rem 2rem);\n  }\n}\n```\n\nЭтого оказалось недостаточно. В следующем патче `code` пришлось сделать `inline-block`, чтобы отступ участвовал в ширине внутреннего блока целиком:\n\n```scss\npre {\n  overflow-x: auto;\n\n  code {\n    display: inline-block;\n    padding: var(--t-code-block-padding, 1.2rem 2rem);\n  }\n}\n```\n\n## Balance оставил только заголовкам\n\nЕщё один эффект дал `text-wrap: balance`. Изначально он стоял и в миксине заголовков, и в миксине абзацев. На обычных абзацах при проверке это поведение оказалось лишним и тяжёлым, поэтому `balance` оставил только для заголовков.\n\n```scss\n@mixin typography-heading($heading-font-size) {\n  \u002F\u002F ...\n  text-wrap: balance;\n}\n```\n\n`typographics` всё ещё содержит fluid-шкалу из первой версии, но теперь поверх неё есть минимальный набор правил для реального документа: текстовые роли, списки, код и вертикальные интервалы, привязанные к `line-height` самих элементов.\n\nПереход на `lh` получился небольшим по синтаксису. Основная работа оказалась вокруг него: проверить, что универсальные правила действительно остаются универсальными на одиночных элементах, длинном прокручиваемом коде и обычных абзацах.\n\n## Материалы\n\n- [CSS Values and Units Level 4: font-relative lengths](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-values-4\u002F#font-relative-lengths)\n- [MDN: `\u003Clength>` и единица `lh`](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FCSS\u002FReference\u002FValues\u002Flength)\n","vertikalnyj-ritm-na-lh-v-typographics","2025-09-20","2026-09-29T13:32:09.133Z",[],{"id":105,"documentId":106,"title":107,"content":108,"slug":109,"author":10,"displayDate":110,"publishedAt":111,"tags":112},80,"rn9rvxezqx4f3ivdrc6voh0s","Один play() для звука в мини-играх","В небольших промо-играх звук обычно нужен в простом формате: пользователь совершил какое-то действие, нажал кнопку, а интерфейс должен на это откликнуться.\n\nЗа таким эффектом быстро появляется однообразная обвязка: загрузить файл, декодировать его через Web Audio, создать источник звука, подключить его к выходу и не забыть про приостановленный `AudioContext` в мобильных браузерах.\n\nПовторять это из проекта в проект не хотелось, поэтому clicktone я сформулировал с узкой задачей: приложение решает, когда должен прозвучать эффект, а библиотека даёт для этого `play()`.\n\n```js\nimport ClickTone from 'clicktone';\n\nconst clickSound = new ClickTone('.\u002Fsound.mp3');\n\nmyButton.addEventListener('click', () => clickSound.play());\n```\n\nДо первого релиза API успел побыть более \"умным\": ClickTone получал DOM-элемент и сам назначал обработчик через `init()`. Перед `1.0.0` я это убрал. Причина события библиотеке не нужна. `click`, клавиатура или внутренняя механика игры — это уже ответственность приложения.\n\n## Спрятать Web Audio, а не событие\n\nВнутри первый `play()` делал обычную цепочку Web Audio:\n\n```js\nfetch(url)\n  .then((response) => response.arrayBuffer())\n  .then((buffer) => this.audioContext.decodeAudioData(buffer))\n  .then((audioData) => {\n    const source = this.audioContext.createBufferSource();\n\n    source.buffer = audioData;\n    source.connect(this.audioContext.destination);\n    source.start(0);\n  });\n```\n\nСам по себе этот код несложный. Польза в том, что он перестаёт расползаться по каждому приложению.\n\nДля touch-устройств в первой версии был отдельный путь восстановления приостановленного `AudioContext`:\n\n```js\nif (this.audioContext.state === 'suspended' && 'ontouchstart' in window) {\n  const unlock = () => {\n    this.audioContext.resume().then(() => {\n      document.body.removeEventListener('touchstart', unlock);\n      document.body.removeEventListener('touchend', unlock);\n    });\n  };\n\n  document.body.addEventListener('touchstart', unlock, false);\n  document.body.addEventListener('touchend', unlock, false);\n}\n```\n\nClickTone здесь не обходит ограничения автовоспроизведения. Если браузеру нужен пользовательский жест для возобновления аудио, библиотека может только держать эту механику в одном месте и вызвать `resume()` в подходящий момент.\n\n## Что действительно понадобилось повторно\n\nПосле нескольких использований библиотеки к `play()` добавились три вещи, которые снова начали повторяться уже поверх базового воспроизведения: громкость, ограничение слишком частых запусков и кеш декодированного аудио.\n\nК `1.2.0` экземпляр можно настроить так:\n\n```js\nconst sound = new ClickTone({\n  file: '.\u002Fclick.mp3',\n  volume: 0.7,\n  throttle: 100,\n  callback: () => console.log('done'),\n  debug: true,\n});\n```\n\nДля `volume` в аудиографе появился `GainNode`:\n\n```js\nconst source = this.audioContext.createBufferSource();\nconst gainNode = this.audioContext.createGain();\n\ngainNode.gain.value = this.volume;\nsource.connect(gainNode);\ngainNode.connect(this.audioContext.destination);\n```\n\n`throttle` проще. Если одно действие приходит слишком часто, новый звук можно не запускать:\n\n```js\nconst now = Date.now();\n\nif (now - this.lastClickTime >= this.throttle) {\n  func();\n  this.lastClickTime = now;\n}\n```\n\nДля коротких эффектов такой временной границы достаточно. Отдельный планировщик здесь ничего полезного не добавил бы.\n\nКеш нужен по другой причине. После первого `fetch()` и `decodeAudioData()` готовый `AudioBuffer` сохраняется по URL:\n\n```js\nif (this.audioCache[url]) {\n  return this.audioCache[url];\n}\n\nconst response = await fetch(url);\nconst buffer = await response.arrayBuffer();\nconst audioData = await this.audioContext.decodeAudioData(buffer);\n\nthis.audioCache[url] = audioData;\n```\n\nСледующий `play()` может сразу использовать декодированный буфер. `AudioBufferSourceNode` при этом всё равно создаётся заново для каждого запуска.\n\n## AudioContext понадобился только в момент воспроизведения\n\nРанние версии создавали `AudioContext` прямо в конструкторе:\n\n```js\nthis.audioContext = new (\n  window.AudioContext || window.webkitAudioContext\n)();\n```\n\nПолучалось, что одного `new ClickTone(...)` достаточно, чтобы поднять аудиоконтекст, даже если звук в этой сессии вообще не пригодится.\n\nВ `1.3.0` создание переехало в путь воспроизведения:\n\n```js\ninitAudioContext() {\n  if (!this.audioContext) {\n    this.audioContext = new (\n      window.AudioContext || window.webkitAudioContext\n    )();\n\n    this.iOSFixAudioContext();\n  }\n}\n```\n\nТеперь экземпляр можно создать заранее, а Web Audio появляется только при первой реальной попытке что-то проиграть. Если после этого `AudioContext` окажется приостановлен, остаётся тот же обходной путь через `resume()`.\n\n## В 1.8.0 источник стал гибче\n\nДо этого `file` был только строкой с URL. В текущем релизе тип расширился:\n\n```ts\ntype FileSource = string | HTMLSourceElement | { id: string };\n```\n\nПрямой URL никуда не делся:\n\n```ts\nconst sound = new ClickTone({\n  file: '.\u002Fclick.mp3',\n});\n```\n\nНо теперь можно передать уже найденный `\u003Csource>`:\n\n```ts\nconst source = document.querySelector(\n  '#click-source',\n) as HTMLSourceElement;\n\nconst sound = new ClickTone({ file: source });\n```\n\nИли попросить clicktone найти его по `id`:\n\n```ts\nconst sound = new ClickTone({\n  file: { id: 'click-source' },\n});\n```\n\nДля `{ id }` библиотека проверяет, что элемент найден, что это `HTMLSourceElement` и что у него есть `src`.\n\nПри необходимости источник можно заменить только для одного запуска:\n\n```ts\nsound.play('.\u002Falt.wav');\n```\n\nЭто не меняет исходную границу библиотеки. Приложение по-прежнему знает, какой звук в какой момент ему нужен. ClickTone забирает себе только повторяющуюся часть вокруг Web Audio.\n\n## Материалы\n\n- [MDN: Web Audio API best practices](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_Audio_API\u002FBest_practices)\n- [MDN: AudioContext.resume()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAudioContext\u002Fresume)\n","odin-play-dlya-zvuka-v-mini-igrah","2025-04-17","2026-09-29T13:33:30.772Z",[],{"id":114,"documentId":115,"title":116,"content":117,"slug":118,"author":10,"displayDate":119,"publishedAt":120,"tags":121},76,"t5o8wrsruzuu542084uuz63y","Дорабатываю Iconly изнутри","В `2.0.0` внешний сценарий почти не изменился, я пересмотрел несколько границ внутри библиотеки: как разрешается `container`, как строка SVG попадает в DOM, как оформляются ошибки, как сам пакет собирается для разных систем модулей. Через несколько дней выпустил `2.0.1`, чтобы поправить `exports` пакета.\n\n## Снаружи почти ничего нового\n\nИспользование `2.0.1` выглядит почти так же, как до мажорного релиза:\n\n```ts\nimport Iconly from 'iconly';\n\nconst iconly = new Iconly({\n  file: '.\u002Fsprite.svg',\n  version: '1.0',\n  debug: true,\n});\n\nawait iconly.init();\n```\n\nИ здесь это принципиально. Задача `2.0` не в том, чтобы придумать новый способ загружать иконки. Я хотел сохранить знакомый вход в библиотеку и пересобрать то, что находится за ним.\n\nTypeScript тоже не относится к нововведениям. TypeScript появился в `1.4.x`. В `2.0` я начал использовать типизацию более последовательно там, где раньше оставались смешанные состояния.\n\n## Селектор больше не превращается в другой контейнер молча\n\nДо `2.0` опция `container` могла быть CSS-селектором или `HTMLElement`. Строковый вариант разрешался примерно так:\n\n```ts\ncontainer: typeof options.container === 'string'\n  ? document.querySelector(options.container) ?? defaultOptions.container\n  : options.container ?? defaultOptions.container\n```\n\nУ такого кода есть удобное свойство: он почти всегда продолжает работать. Если селектор ошибочный или элемента ещё нет, iconly просто использует контейнер по умолчанию.\n\nНо в этом же и проблема. Я передал конкретный селектор, библиотека не смогла его найти, а результатом становится работа в совсем другом DOM-узле. Ошибка конфигурации превращается в незаметный переход к значению по умолчанию.\n\nВ `2.0` я сначала полностью разрешаю `container`:\n\n```ts\nlet containerEl: HTMLElement;\n\nif (typeof merged.container === 'string') {\n  const found = document.querySelector(merged.container);\n\n  if (!found || !(found instanceof HTMLElement)) {\n    throw new Error(`Invalid container selector: \"${merged.container}\"`);\n  }\n\n  containerEl = found;\n} else {\n  containerEl = merged.container;\n}\n\nthis.container = containerEl;\n```\n\nПосле конструктора внутри класса больше нет состояния `string | HTMLElement`. Есть только `HTMLElement`.\n\nЗдесь я сознательно поменял поведение. Невалидный селектор теперь не означает \"ладно, положим в `body`\". Он означает ошибку конфигурации.\n\nЭто небольшой фрагмент, но именно такие места и хотелось привести в порядок: не заставлять последующий код помнить о нескольких возможных состояниях, если их можно разрешить один раз на границе.\n\n## SVG сначала становится документом\n\nВ `1.5.1` вставка спрайта была максимально прямой:\n\n```ts\niconSetDiv.innerHTML = data;\n```\n\n`data` — строка, полученная из SVG-файла. Браузер разбирает её уже в момент присваивания `innerHTML`.\n\nВ `2.0` я разделил эти операции. Сначала строка явно разбирается как SVG-документ:\n\n```ts\nconst parser = new DOMParser();\nconst svgDoc = parser.parseFromString(data, 'image\u002Fsvg+xml');\nconst parserError = svgDoc.querySelector('parsererror');\n\nif (parserError) {\n  this.logError('SVG parsing error:', parserError.textContent ?? '');\n  return;\n}\n```\n\nПосле этого корневой SVG переносится в текущий документ:\n\n```ts\niconSetDiv.innerHTML = '';\n\nconst svgEl = svgDoc.documentElement;\n\nif (svgEl) {\n  const imported = document.importNode(svgEl, true);\n  iconSetDiv.appendChild(imported);\n} else {\n  this.logError('No valid SVG content found to insert.');\n}\n```\n\nДля меня здесь важны две вещи.\n\nВо-первых, SVG больше не проходит через код как непрозрачная строка до самого `innerHTML`. У него появляется отдельный этап разбора как `image\u002Fsvg+xml`.\n\nВо-вторых, появляется конкретная точка, где можно обнаружить ошибку парсинга до вставки элемента в основной документ.\n\nПри этом `DOMParser` не стоит путать с санитайзером. Этот код не делает недоверенный SVG безопасным и не решает все возможные проблемы содержимого. В этом релизе задача более узкая: работать с SVG как с SVG-документом, а не только как со строкой HTML.\n\n## Ошибки стали конкретнее, но контракт пока простой\n\nДо рефакторинга многие ошибки IndexedDB просто пробрасывали `request.error` или `tx.error`. В `2.0` я добавил небольшую нормализацию:\n\n```ts\nprivate createErrorMessage(err: unknown, fallback: string): string {\n  if (!err) {\n    return fallback;\n  }\n\n  if (err instanceof DOMException || err instanceof Error) {\n    return err.message || fallback;\n  }\n\n  return fallback;\n}\n```\n\nЗаодно сообщения привязаны к конкретной стадии:\n\n```ts\n'Failed to fetch icons from \"...\"'\n'Error getting record from store'\n'Error putting record into store'\n'Transaction error'\n'Transaction aborted'\n```\n\nЭто полезнее общего `Network response was not ok` или сырого объекта ошибки. Когда операция проходит через `fetch`, IndexedDB и DOM, хотя бы понятно, на какой границе она остановилась.\n\nНо публичный контракт ошибок я в этом релизе не менял. `init()` всё ещё возвращает `Promise\u003Cvoid>` и ловит ошибки выполнения внутри:\n\n```ts\npublic async init(): Promise\u003Cvoid> {\n  try {\n    \u002F\u002F fetch, IndexedDB, insert\n  } catch (err: unknown) {\n    const e = err instanceof Error ? err : new Error(String(err));\n    this.logError('Error initializing Iconly:', e.message);\n  }\n}\n```\n\nТо есть вызывающий код не получает структурированный результат операции и не обязан оборачивать `init()` в `try\u002Fcatch` для этих ошибок. Пока я оставлю эту модель как есть. Цель `2.0` — сделать саму реализацию понятнее, не перепроектировать весь публичный API одновременно.\n\n## Сборка — часть библиотеки\n\nДо `2.0` пакет собирался Parcel. В мажорном релизе я перевёл сборку библиотеки на Vite и явно задал три формата:\n\n```ts\nlib: {\n  entry: 'src\u002Findex.ts',\n  name: 'Iconly',\n  formats: ['es', 'cjs', 'umd'],\n  fileName: (format) => `index.${format}.js`,\n},\n```\n\nТипы собираются отдельно через `vite-plugin-dts`, а `package.json` указывает основные выходы:\n\n```json\n{\n  \"main\": \"dist\u002Findex.cjs.js\",\n  \"module\": \"dist\u002Findex.es.js\",\n  \"browser\": \".\u002Fdist\u002Findex.umd.js\",\n  \"types\": \"dist\u002Findex.d.ts\"\n}\n```\n\nВ `2.0.0` сразу добавил и условные `exports` для `require`, `import` и запасной вариант на UMD.\n\nПервый вариант оказался не последним. В `2.0.1` корневой экспорт оформлен явно через `\".\"`, а `dist` открыт отдельным подпутём:\n\n```json\n{\n  \"exports\": {\n    \".\": {\n      \"require\": \".\u002Fdist\u002Findex.cjs.js\",\n      \"import\": \".\u002Fdist\u002Findex.es.js\",\n      \"default\": \".\u002Fdist\u002Findex.umd.js\"\n    },\n    \".\u002Fdist\u002F*\": \".\u002Fdist\u002F*\"\n  }\n}\n```\n\n## Материалы\n\n- [MDN: DOMParser.parseFromString()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FDOMParser\u002FparseFromString)\n- [MDN: Document.importNode()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FDocument\u002FimportNode)\n","dorabatyvayu-iconly-iznutri","2025-03-10","2026-09-29T13:32:46.794Z",[],{"id":123,"documentId":124,"title":125,"content":126,"slug":127,"author":10,"displayDate":128,"publishedAt":129,"tags":130},84,"r0jxt6a2huml847j89tiyxrk","Расстаёмся с Custom Element","Custom Element давал `marquee-content` готовые методы жизненного цикла. Проблема появилась при интеграции с фреймворком: одним DOM-узлом одновременно управляли браузер и приложение.\n\nК версии 4 я хотел управлять инициализацией явно. Заодно накопились задачи по типам, форматам сборки и устройству npm-пакета. В результате один архитектурный переход растянулся на несколько релизов, а TypeScript пришлось внедрять дважды. С первого раза он формально появился, но ещё не помогал.\n\n## Последний Custom Element\n\nВ версии `3.1.1` библиотека всё ещё экспортировала класс, унаследованный от `HTMLElement`:\n\n```js\nconnectedCallback() {\n  this.init();\n}\n\ndisconnectedCallback() {\n  this.destroy();\n}\n```\n\nПользователь добавляет собственный тег, а браузер запускает `connectedCallback()`. При удалении элемента `disconnectedCallback()` вызывал `destroy()`: текущий Tween останавливался, `this.af` отменялся, а `ResizeObserver` отключался. Очистка оставалась неполной. Отложенный кадр внутри debounce и контексты `matchMedia` не отменялись, а после повторного подключения observer уже не восстанавливался.\n\nДля изолированного компонента такая схема удобна. Во фреймворке появляется второй жизненный цикл: приложение управляет компонентом страницы, браузер — Custom Element внутри него. Инициализацию и очистку приходится согласовывать между ними.\n\nМне требовались три вещи:\n- Принимать уже существующий DOM-элемент, а не требовать специальный тег;\n- Явно запускать и останавливать анимацию вместе с компонентом приложения;\n- Сократить количество неявного поведения внутри библиотеки.\n\nПоэтому в `4.0.0` `MarqueeContent` перестал наследоваться от `HTMLElement`, а управление инициализацией и очисткой перешло к вызывающему коду.\n\n## Обычный класс и явные init() \u002F destroy()\n\nРазметка стала обычной:\n\n```html\n\u003Cdiv\n  class=\"marquee\"\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n>\n  \u003Cspan>Primary\u003C\u002Fspan>\n  \u003Cspan>Secondary\u003C\u002Fspan>\n  \u003Cspan>Tertiary\u003C\u002Fspan>\n\u003C\u002Fdiv>\n```\n\nВместо регистрации HTML-тега пользователь создавал экземпляр класса и вызывал `init()`:\n\n```js\nconst marquee = new MarqueeContent({\n  element: '.marquee',\n});\n\nmarquee.init();\n```\n\nПри размонтировании нужно было симметрично вызвать:\n\n```js\nmarquee.destroy();\n```\n\nПервый вариант API в `4.0.0` принимал элемент или селектор напрямую. Уже в `4.1.0` конструктор получил объект настроек `{ element }`. Такой вызов немного длиннее, зато его проще расширять, не добавляя позиционные аргументы.\n\nДо `4.5.0` отсутствующий элемент приводил к раннему выходу из конструктора и оставлял частично созданный экземпляр. В `4.6.0` форма `{ element }` сохранилась, но ошибка снова стала явной: конструктор бросал `Target element not found`.\n\nЯвные `init()` и `destroy()` можно привязать к монтированию и размонтированию компонента во фреймворке. Очистка при этом становится обязанностью пользователя: без `destroy()` `ResizeObserver` и ScrollTrigger продолжат жить после удаления DOM-узла.\n\n## Зависимости нужно передавать полностью\n\nВ ранней реализации четвёртой версии, GSAP регистрировался через статический метод, но `ScrollTrigger.refresh()` всё ещё вызывался через глобальный `ScrollTrigger`. Модульный API получился не до конца модульным.\n\nВ `4.2.0` регистрация стала явной для обеих зависимостей:\n\n```js\ngsap.registerPlugin(ScrollTrigger);\nMarqueeContent.registerGSAP(gsap, ScrollTrigger);\n```\n\nМетод `registerGSAP()` сохранял обе ссылки и убирал свободное глобальное имя из пути `refresh()`. Регистрация плагина в самом GSAP оставалась отдельной операцией. В `4.6.0` импортированные GSAP и ScrollTrigger уже служили значениями по умолчанию, а статический метод позволял их переопределить.\n\nВ пакетах такие детали важнее, чем в коде одной страницы. Приложение может рассчитывать на глобальный объект, потому что само контролирует порядок скриптов. Библиотека не должна молча предполагать, что нужное имя уже существует в `window`.\n\n## Первая попытка TypeScript\n\nПо `4.2.0` включительно исходники оставались на JavaScript и собирались Parcel. В версии `4.3.0` основной файл стал TypeScript, появился `tsconfig.json`.\n\nНа уровне списка файлов задача выглядела выполненной. На уровне публичного API — ещё нет.\n\nПараметры регистрации GSAP и часть полей получили тип `never`:\n\n```ts\nstatic registerGSAP(gsap: never, ScrollTrigger: never): void;\n```\n\nТакой тип утверждает, что допустимого значения не существует. Реальный JS ожидал экземпляры GSAP и ScrollTrigger, но декларация не могла выразить этот контракт. Типы были в npm-пакете, однако использовать их по назначению было трудно.\n\nЭто хороший пример разницы между \"код переписан на TypeScript\" и \"у библиотеки появился типизированный API\". Компилятор проверяет только ту модель, которую ему дали. Если на границе стоят `never` или безразмерный `any`, наличие `.ts` ещё ничего не гарантирует пользователю.\n\n## Duration переименован в speed\n\nВ версии `4.4.0` заменил атрибут `data-mc-duration` на `data-mc-speed`:\n\n```html\n\u003Cdiv class=\"marquee\" data-mc-speed=\"20\">\n  \u003C!-- элементы ленты -->\n\u003C\u002Fdiv>\n```\n\nМатематика анимации при этом не изменилась. Значение по-прежнему передавалось в GSAP как `duration`:\n\n```js\ntimeline.to(element.children, {\n  duration: speed,\n  x: '-100%',\n});\n```\n\nПоэтому меньшее значение двигало ленту быстрее, а большее — медленнее. `speed` здесь означал пользовательскую настройку темпа, а не физическую скорость в пикселях в секунду. Имя стало короче, но семантика осталась обратной привычной скорости.\n\nДля HTML это было несовместимое изменение: старый `data-mc-duration` библиотека больше не читала. Переименование публичного атрибута оказалось заметнее, чем переход исходников на TypeScript.\n\n## Шаг назад без маскировки\n\nВ `4.5.0` убрал TypeScript вместе с декларациями, а инкапсуляция переехала на нативные приватные поля JavaScript:\n\n```js\nclass MarqueeContent {\n  #element;\n  #timeline;\n  #resizeObserver;\n}\n```\n\nМожно было оставить первую миграцию, но пользы от такой последовательности немного. Плохие типы не становятся хорошими от того, что дольше лежат в пакете.\n\nВозврат к JavaScript был промежуточным состоянием. Публичный API не изменился: `{ element }`, `init()`, `destroy()` и `registerGSAP()` продолжили работать как раньше.\n\n## TypeScript со второй попытки\n\nК версии `4.6.0` я вернул TypeScript, но начал с границы пакета. В декларации появились реальные типы GSAP и ScrollTrigger, а параметры элемента были описаны как строковый селектор или `HTMLElement`.\n\n```ts\ntype MarqueeContentOptions = {\n  element?: string | HTMLElement;\n};\n```\n\nТеперь типы соответствовали способу использования библиотеки, а не просто факту существования TypeScript-файла.\n\nОдновременно сборка переехала с Parcel на Vite в режиме сборки библиотек. Пакет начал публиковать три формата:\n\n```json\n{\n  \"main\": \"dist\u002Findex.cjs.js\",\n  \"module\": \"dist\u002Findex.es.js\",\n  \"browser\": \".\u002Fdist\u002Findex.umd.js\",\n  \"types\": \"dist\u002Findex.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"require\": \".\u002Fdist\u002Findex.cjs.js\",\n      \"import\": \".\u002Fdist\u002Findex.es.js\",\n      \"default\": \".\u002Fdist\u002Findex.umd.js\"\n    }\n  }\n}\n```\n\nCommonJS оставался доступен через `require`, ESM — через `import`, UMD — для прямого браузерного подключения. `types` указывало на декларацию, а `exports` задавал явные точки входа вместо надежды на догадки сборщика.\n\nGSAP при этом не попал внутрь сборки: Vite оставлял `gsap` и `gsap\u002FScrollTrigger` внешними модулями. Это предотвращало появление второй копии GSAP в приложении, но сохранило обязанность пользователя установить и зарегистрировать зависимость.\n\n## Что осталось\n\nСмена архитектуры не закрыла весь техдолг.\n\n`gsap.matchMedia().add()` по-прежнему вызывается при повторной настройке, а общий `revert()` при `destroy()` отсутствует. Ручное управление жизненным циклом требует дисциплины. Кроме того, GSAP в `4.6.0` используется во время выполнения, но указан только в `devDependencies`, а не в `peerDependencies`.\n\n## Материалы\n\n- [MDN: Using custom elements](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_components\u002FUsing_custom_elements)\n- [TypeScript: Declaration Files](https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002Fdeclaration-files\u002Fintroduction.html)\n- [Vite: Library Mode](https:\u002F\u002Fvite.dev\u002Fguide\u002Fbuild.html#library-mode)\n- [Node.js: Package entry points](https:\u002F\u002Fnodejs.org\u002Fapi\u002Fpackages.html#package-entry-points)\n- [GSAP: Install](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FInstallation\u002F)\n","custom-element-uhodit-type-script-prihodit","2025-03-09","2026-09-29T14:00:13.465Z",[],{"id":132,"documentId":133,"title":134,"content":135,"slug":136,"author":10,"displayDate":128,"publishedAt":137,"tags":138},81,"tkj2cgfjisq0rr061p90eusp","Один контроллер вместо диалогов под каждый проект","В разных проектах я снова и снова делал примерно одно и то же: находил контейнер диалога, открывал его, закрывал по кнопке или фону, переключал классы для анимации и следил, чтобы после закрытия страница вернулась в нормальное состояние.\n\nРазметка и внешний вид каждый раз отличались, но управляющий код повторялся. Готовые библиотеки для этой задачи казались заметно тяжелее, чем сама задача, поэтому прошлым летом я решил собрать накопившийся опыт в небольшой переиспользуемый контроллер.\n\nТак появился dialog-lite. К версии `1.5.1` его внешний API состоит из нескольких действий: создать экземпляр, вызвать `init()`, затем открывать и закрывать диалог через `open()` и `close()`.\n\n```ts\nimport DialogLite from 'dialog-lite';\nimport 'dialog-lite\u002Fdist\u002Findex.css';\n\nconst dialog = new DialogLite({\n  closingButton: true,\n  closingBackdrop: true,\n});\n\ndialog.init();\n\nbutton.addEventListener('click', () => {\n  dialog.open({\n    stylingClass: 'dialog-lite--first-window',\n  });\n});\n```\n\nЗдесь важнее не сократить пару строк, а один раз определить границу ответственности. Приложение решает, когда нужен диалог и чем он заполнен. DialogLite занимается его состоянием и повторяющейся DOM-логикой.\n\n## Не делать из контроллера готовый UI-компонент\n\nDialogLite не создаёт разметку диалогового окна из JavaScript. Он работает с уже существующим DOM и ожидает несколько известных селекторов:\n\n```ts\nthis.dialogEl = document.querySelector\u003CHTMLDivElement>('.dialog-lite');\nthis.mainContentEl = document.getElementById('main-content');\n\nif (!this.dialogEl) {\n  throw new Error('Dialog element not found');\n}\n\nthis.dialogCloseEl = this.dialogEl.querySelector\u003CHTMLButtonElement>(\n  '.dialog-lite-close-button',\n);\n\nthis.dialogBackdropEl = this.dialogEl.querySelector\u003CHTMLDivElement>(\n  '.dialog-lite__backdrop',\n);\n```\n\nЭто оставляет содержимое окна обычной частью приложения. Внутри может быть форма, текст, кнопки или любая другая разметка. Контроллеру не нужно знать её структуру.\n\nИз настроек конструктор принимает два флага:\n\n```ts\nconst dialog = new DialogLite({\n  closingButton: true,\n  closingBackdrop: true,\n});\n```\n\nОни отвечают за закрытие по кнопке и фону. `init()` добавляет соответствующие обработчики, а Escape работает через отдельный обработчик `keydown` и вызывает `close()` только когда окно открыто.\n\n```ts\ndocument.addEventListener('keydown', (event: KeyboardEvent) => {\n  if (event.key === 'Escape' && this.isOpen) {\n    this.close();\n  }\n});\n```\n\nКонтракт с разметкой довольно жёсткий, зато сам класс остаётся маленьким. Если `.dialog-lite` не найден, лучше сразу получить понятную ошибку, чем экземпляр, который молча ничего не делает.\n\n## Закрытие оказалось не мгновенным действием\n\nВ самой первой версии `open()` и `close()` в основном переключали два класса:\n\n- `dialog-lite--in` для открытого состояния\n- `dialog-lite--out` для закрытия\n\nДополнительно `open()` может получить `stylingClass`. Это позволяет добавить к тому же контейнеру класс конкретного сценария:\n\n```ts\ndialog.open({\n  stylingClass: 'dialog-lite--first-window',\n});\n```\n\nСначала закрытие выглядело просто: убрать текущий класс оформления и заменить `--in` на `--out`. При обкатке быстро выяснилось, что этого мало.\n\nКласс оформления нельзя снимать до завершения закрывающего перехода, поэтому его удаление пришлось отложить:\n\n```ts\nif (this.currentClass) {\n  if (delayRemove) {\n    const classToRemove = this.currentClass;\n\n    setTimeout(() => {\n      this.dialogEl?.classList.remove(classToRemove);\n      this.currentClass = '';\n    }, 500);\n  } else {\n    this.dialogEl.classList.remove(this.currentClass);\n  }\n}\n```\n\nВ `1.5.1` само закрытие тоже завершается не сразу. Сначала контроллер переводит диалог в состояние `dialog-lite--out`, а через `500ms` окончательно скрывает его через `style.display = 'none'`:\n\n```ts\nthis.updateClassList({\n  addClass: 'dialog-lite--out',\n  removeClass: 'dialog-lite--in',\n  newClass: '',\n  delayRemove: true,\n});\n\nsetTimeout(() => {\n  if (this.dialogEl) {\n    this.dialogEl.style.display = 'none';\n  }\n}, 500);\n```\n\nПри следующем открытии `display: none` снимается до переключения классов. Чтение `offsetWidth` принудительно завершает пересчёт геометрии перед началом нового перехода:\n\n```ts\nif (this.dialogEl.style.display === 'none') {\n  this.dialogEl.style.display = '';\n  void this.dialogEl.offsetWidth;\n}\n```\n\nВ `1.5.1` задержка просто захардкожена на `500ms` и синхронизирована с текущими стилями пакета. Фактическую продолжительность пользовательской CSS-анимации библиотека не вычисляет.\n\n## Повторный вызов тоже часть состояния\n\nСледующая проблема проявилась там же, во время обкатки ранней версии. Пока идёт переход, `open()` и `close()` можно вызвать ещё раз.\n\nЕсли разрешить такие команды без ограничений, классы начинают переключаться быстрее, чем интерфейс успевает завершить предыдущую фазу. Для небольшого контроллера я выбрал простое ограничение повторных вызовов на те же `500ms`:\n\n```ts\nprivate isDebounced(): boolean {\n  const now = Date.now();\n\n  if (now - this.lastActionTime \u003C 500) return true;\n\n  this.lastActionTime = now;\n  return false;\n}\n```\n\nИ `open()`, и `close()` начинают с этой проверки:\n\n```ts\nif (this.isDebounced()) return;\n```\n\nПовторный вызов в течение `500ms` просто игнорируется. Для этой версии этого хватает. Полноценная машина состояний для такой маленькой задачи уже перебор.\n\n## Открыть и закрыть мало\n\nПо мере обкатки вокруг переключения классов добавились ещё несколько небольших обязанностей.\n\nПри открытии DialogLite отмечает основной контент как скрытый через `aria-hidden`, а сам диалог — как видимый:\n\n```ts\nif (this.mainContentEl) {\n  this.mainContentEl.setAttribute('aria-hidden', 'true');\n}\n\nthis.dialogEl.setAttribute('aria-hidden', 'false');\nthis.previouslyFocusedElement = document.activeElement as HTMLElement;\n```\n\nПри закрытии атрибуты переключаются обратно, а фокус возвращается на элемент, который был активен перед открытием:\n\n```ts\nif (this.mainContentEl) {\n  this.mainContentEl.setAttribute('aria-hidden', 'false');\n}\n\nthis.dialogEl.setAttribute('aria-hidden', 'true');\n\nif (this.previouslyFocusedElement) {\n  this.previouslyFocusedElement.focus();\n}\n```\n\nПолноценную модель доступности модального окна эта версия ещё не реализует. Контроллер синхронизирует `aria-hidden` и возвращает фокус на элемент, с которого окно было открыто.\n\nЕщё один баг нашёлся с фокусом. Код без проверки искал `[tabindex=\"0\"]` и сразу вызывал `focus()`. В диалоге без такого элемента он закономерно падал, поэтому добавил проверку.\n\nУ `1.5.1` остаётся довольно жёсткий контракт с приложением. DialogLite берёт первый `.dialog-lite`, знает селекторы кнопки закрытия и фона, при наличии работает с `#main-content`, а ограничение повторных вызовов и задержка закрытия зафиксированы в коде на `500ms`.\n\nДо универсального движка диалогов здесь далеко, но такой цели пока и не было. Главное, что базовую логику диалога больше не нужно заново писать под каждый проект.\n","odin-kontroller-vmesto-dialogov-pod-kazhdyj-proekt","2026-09-29T13:33:39.580Z",[],{"id":140,"documentId":141,"title":142,"content":143,"slug":144,"author":10,"displayDate":145,"publishedAt":146,"tags":147},67,"ttv07xomzxiijk85sdob4rlx","SVG-спрайт в localStorage","Кэшировать SVG-спрайт в браузере сначала кажется задачей на несколько строк. Получил файл, положил строку в `localStorage`, при следующем запуске достал её обратно.\n\nСложность начинается с вопроса: как понять, что сохранённая копия ещё актуальна?\n\nВ ранней версии iconly я отвечал на него параметром `revision`. При изменении спрайта нужно было изменить и ревизию. Механизм работал, но требовал помнить о ручном действии при каждом обновлении набора иконок. Как раз от этого я хотел избавиться.\n\nЗа следующие версии решение прошло через проверку длины SVG и в итоге переехало в IndexedDB.\n\n## Как не забыть ревизию\n\nВ `1.0.0` логика была прямолинейной. Передаю URL спрайта и его ревизию, а iconly сравнивает её со значением в `localStorage`:\n\n```js\nconst { file, revision } = this.options;\n\nif (\n  this.isLocalStorage\n  && localStorage.getItem('inlineSVGrev') === revision\n) {\n  const data = localStorage.getItem('inlineSVGdata');\n\n  if (data) {\n    this.insert(data);\n    return;\n  }\n}\n```\n\nЕсли ревизия совпала, можно взять сохранённый SVG и вообще не обращаться к сети. Если нет, библиотека загружает файл и обновляет `inlineSVGdata` вместе с `inlineSVGrev`.\n\nПроблема здесь не техническая, а эксплуатационная. Механизм инвалидирования существует вне самого файла. Изменил `sprite.svg`, забыл изменить `revision` — получил старую копию из браузера.\n\nЯ не хотел держать это правило в голове, поэтому убрал обязательную ревизию и попробовал определить изменение автоматически.\n\n## Размер строки вместо отдельной ревизии\n\nСледующий вариант сохранял рядом со спрайтом его длину:\n\n```js\nconst storedSize = localStorage.getItem('inlineSVGsize');\nconst response = await fetch(file);\nconst data = await response.text();\n\nif (storedSize && storedSize === data.length.toString()) {\n  this.insert(localStorage.getItem('inlineSVGdata'));\n} else {\n  this.insert(data);\n\n  localStorage.setItem('inlineSVGdata', data);\n  localStorage.setItem('inlineSVGsize', data.length.toString());\n}\n```\n\nДля API это было удобнее. Пользователю больше не нужно передавать `revision`, а изменение длины файла автоматически обновляет сохранённую строку.\n\nНо это именно эвристика. Два разных SVG могут иметь одинаковую длину. Кроме того, для сравнения iconly всё равно сначала полностью загружает файл. То есть `localStorage` в этой реализации не экономит сетевой запрос. Он хранит копию, но для решения о её актуальности уже нужен свежий ответ сервера.\n\nНа этом этапе меня такой компромисс устраивал больше ручного `revision`, но параллельно росли сами спрайты. Хранить всё более крупные SVG-строки в `localStorage` мне нравилось всё меньше.\n\n## Откуда IndexedDB\n\nУ перехода было три причины: размер спрайтов, желание получить более надёжное хранилище и эксперимент с IndexedDB.\n\n`localStorage` хорош для небольших данных в формате ключ\u002Fзначение, но его API синхронный. IndexedDB устроен иначе: работа с ним асинхронная, данные организованы через базы, хранилища объектов, ключи и транзакции. Для заметно растущего набора данных это выглядело более подходящей основой.\n\nВ `1.4.0` iconly открывает одну базу `iconlyDB` и создаёт хранилище `icons`:\n\n```js\nconst request = indexedDB.open('iconlyDB', 1);\n\nrequest.onupgradeneeded = (event) => {\n  const db = event.target.result;\n\n  if (!db.objectStoreNames.contains('icons')) {\n    db.createObjectStore('icons', { keyPath: 'version' });\n  }\n};\n```\n\nВместо двух строк в `localStorage` теперь хранится объект:\n\n```js\n{\n  version,\n  data,\n}\n```\n\n`version` становится ключом записи. И вместе с этим меняется публичная конфигурация:\n\n```js\nconst iconly = new Iconly({\n  file: '.\u002Fsprite.svg',\n  version: '1.0',\n  debug: true,\n});\n\niconly.init();\n```\n\nК этому моменту `init()` уже вызывался явно после создания экземпляра.\n\n## Что на самом деле делает текущий кэш\n\nПосле перехода на IndexedDB легко сказать: теперь iconly сначала смотрит в кэш и только при необходимости загружает SVG. Но текущий код работает не так.\n\n`init()` сначала получает файл:\n\n```ts\nlet data = await Iconly.fetchData(file);\nconst db = await Iconly.dbInstance;\nconst store = db\n  .transaction('icons', 'readwrite')\n  .objectStore('icons');\n```\n\nИ только после этого читает запись по `version`:\n\n```ts\nconst dbVersion = await new Promise((resolve, reject) => {\n  const request = store.get(version);\n\n  request.onsuccess = () => resolve(request.result);\n  request.onerror = () => reject(request.error);\n});\n\nif (!dbVersion) {\n  await new Promise((resolve, reject) => {\n    const request = store.put({ version, data });\n\n    request.onsuccess = () => resolve();\n    request.onerror = () => reject(request.error);\n  });\n} else {\n  data = dbVersion.data;\n}\n```\n\nЕсли записи с такой версией ещё нет, свежий SVG попадает в IndexedDB. Если запись уже существует, iconly использует сохранённые данные.\n\nЭто даёт важное ограничение текущей реализации. Сетевой запрос всё ещё происходит при каждом `init()`. Более того, если содержимое `sprite.svg` изменилось, а `version` осталось прежним, сохранённая запись победит только что загруженную строку.\n\nПолучается интересный компромисс. До этого я убрал `revision`, потому что не хотел помнить о её обновлении. С IndexedDB явная версия набора снова стала частью API, теперь уже как ключ записи. Автоматическая проверка длины исчезла.\n\nЯ не хочу маскировать это словом \"кэш\" и приписывать текущей версии свойства, которых у неё нет. На этом этапе задача была другой: перенести хранение SVG из `localStorage` в более подходящий механизм и разобраться с его моделью работы.\n\n## Транзакции оказались отдельной частью задачи\n\nУ `localStorage` нет жизненного цикла транзакции. Вызвал `setItem()` — операция завершилась синхронно.\n\nС IndexedDB нужно дождаться открытия базы, выполнить `get()` или `put()`, а затем корректно обработать завершение транзакции. В первых вариантах после миграции эта часть ещё менялась.\n\nК `1.4.4` ожидание завершения уже обёрнуто в обычный Promise:\n\n```ts\nawait new Promise\u003Cvoid>((resolve, reject) => {\n  tx.oncomplete = () => resolve();\n  tx.onerror = () => reject(tx.error);\n  tx.onabort = () => reject(tx.error);\n});\n```\n\nВ этом же релизе исходник переехал с JavaScript на TypeScript. Это хорошо совпало с IndexedDB-кодом, где быстро появляется много объектов со своими типами: `IDBDatabase`, `IDBOpenDBRequest`, `IDBVersionChangeEvent`, транзакции и хранилища.\n\nВ `1.5.0` я ещё раз переработал расположение вызовов транзакций. Семантика хранения не изменилась, но код стал ближе к `1.5.1`.\n\nМиграция хранилища тем самым оказалась не простой заменой `localStorage.setItem()` на другой метод. Вместе с IndexedDB в библиотеке появились отдельное открытие базы, схема хранилища, обработчики запросов и жизненный цикл транзакций.\n\n## Ещё один побочный эффект: как прятать спрайт\n\nПосле перехода на новый DOM-контейнер спрайт вставляется в `div#iconset`. Один из промежуточных вариантов скрывал этот контейнер через `display: none`:\n\n```css\nwidth: 0px;\nheight: 0px;\ndisplay: none;\n```\n\nПосле обнаруженной проблемы с видимостью я заменил его на вынесенный за экран нулевой контейнер:\n\n```css\nwidth: 0;\nheight: 0;\nposition: absolute;\nleft: -9999px;\n```\n\nСнаружи API остаётся небольшим:\n\n```ts\nimport Iconly from 'iconly';\n\nconst iconly = new Iconly({\n  file: '.\u002Fsprite.svg',\n  version: '1.0',\n  debug: true,\n});\n\nawait iconly.init();\n```\n\nКроме `file`, `version` и `debug` можно передать `container` как CSS-селектор или `HTMLElement`. По умолчанию спрайт добавляется в `document.body`.\n\nТекущий вариант не закрыл тему окончательно. Сеть осталась обязательной частью `init()`, актуальность IndexedDB-записи зависит от переданного `version`. Зато теперь эти ограничения находятся в модели, которую можно развивать дальше.\n\n## Материалы\n\n- [MDN: Web Storage API](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_Storage_API)\n- [MDN: IndexedDB API](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FIndexedDB_API)\n","svg-sprajt-v-local-storage","2024-06-02","2026-09-29T13:31:22.717Z",[],{"id":149,"documentId":150,"title":151,"content":152,"slug":153,"author":10,"displayDate":154,"publishedAt":155,"tags":156},71,"ewuzaa0hw5kfc6gzqph0k67u","Бесконечная анимация, конечный жизненный цикл","Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.\n\nПри ручных проверках `marquee-content` стал создавать заметную нагрузку. Смотрел поведение в DevTools, проверял компонент на реальных устройствах, несколько раз перечитывал код. Одного красивого места с подписью \"вот утечка\" не оказалось. Нагрузка складывалась из мелочей: повторной инициализации, новых обработчиков прокрутки, неполной очистки при удалении элемента.\n\n`marquee-content@3.0.0` опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\n\n## API оставался стабильным\n\nМежду версиями `1.9.1` и `3.0.0` компонент по-прежнему подключается как Custom Element:\n\n```html\n\u003Cmarquee-content\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n>\n  \u003Cul>\n    \u003Cli>Primary\u003C\u002Fli>\n    \u003Cli>Secondary\u003C\u002Fli>\n    \u003Cli>Tertiary\u003C\u002Fli>\n  \u003C\u002Ful>\n\u003C\u002Fmarquee-content>\n```\n\nСохранились основные атрибуты:\n\n- `data-mc-duration`;\n- `data-mc-direction`;\n- `data-mc-skew`;\n- `data-mc-min`;\n- `data-mc-max`.\n\nGSAP всё так же можно передать явно через `MarqueeContent.registerGSAP(gsap)`.\n\n## Как накапливается лишняя работа\n\nПосле подключения элемента вызывается `connectedCallback()`, а затем `init()`. Компонент создаёт обёртки, считает клоны, применяет skew и запускает GSAP-анимацию.\n\nШирину отслеживает `ResizeObserver`. При каждом обновлении размеров компонент заново рассчитывает клоны и пересоздаёт анимацию. Само по себе это ожидаемо: после изменения ширины старая геометрия уже не гарантирует непрерывную ленту.\n\nПроблема была в деталях.\n\nВ версии `2.4.1` обновление после изменения размера проходило через `setTimeout` с задержкой `150ms`, а затем через `requestAnimationFrame`:\n\n```js\ndebounce(fn, delay) {\n  this.timer = null;\n\n  return (...args) => {\n    if (this.timer) clearTimeout(this.timer);\n    this.timer = setTimeout(() => fn(...args), delay);\n  };\n}\n```\n\nПосле таймера `update()` планировал ещё один кадр, в котором выполнялись клонирование и пересоздание анимации. Код работал, но обработка была привязана и к произвольной задержке, и к циклу отрисовки браузера.\n\nДля направления `auto` каждая новая анимация добавляла собственный обработчик:\n\n```js\nwindow.addEventListener('scroll', handleScroll, {\n  capture: true,\n  passive: true,\n});\n```\n\nПри `resize` метод `animation()` запускался снова. Предыдущая анимация уже умела завершаться, а вот созданный внутри `autoDirection()` обработчик `scroll` не удалялся. Несколько пересборок компонента могли оставить несколько обработчиков одного события.\n\nНаконец, `disconnectedCallback()` останавливал запланированный кадр и завершал анимацию, но не отключал `ResizeObserver`. DOM-элемент уже удалён, а связанная с ним инфраструктура ещё не получила эту новость.\n\nОтдельно каждый пункт выглядел терпимо. Вместе они объясняли, почему компонент с довольно простой анимацией начинал вести себя тяжелее, чем должен.\n\n## Вместо таймера — один кадр\n\nВ версии 3.0 debounce больше не использует `setTimeout`:\n\n```js\ndebounce = () => {\n  let timer;\n\n  return () => {\n    cancelAnimationFrame(timer);\n    timer = requestAnimationFrame(this.update);\n  };\n};\n```\n\nКаждый новый вызов отменяет ранее запланированную функцию и оставляет только один `update`. Серия событий изменения размера больше не создаёт очередь таймеров. Обновление синхронизируется с циклом отрисовки браузера.\n\nСам `update()` по-прежнему планирует фактическую пересборку через `requestAnimationFrame`:\n\n```js\nupdate() {\n  cancelAnimationFrame(this.af);\n\n  if (this.firstElementChild) {\n    this.af = requestAnimationFrame(() => {\n      this.cloning();\n      this.animation();\n    });\n  }\n}\n```\n\nПолучилась двухступенчатая схема через `requestAnimationFrame`. Первый кадр объединяет частые сигналы `ResizeObserver`, второй выполняет работу с DOM и анимацией. Она не делает пересборку бесплатной, но и не позволяет одному всплеску событий запланировать несколько одинаковых обновлений.\n\n## ScrollTrigger уже знает направление\n\nСобственный `window.addEventListener('scroll', ...)` оказался лишним. ScrollTrigger и так обновляется при прокрутке и передаёт в `onUpdate` текущий экземпляр со свойством `direction`.\n\nВ версии 3.0 направление меняется внутри уже существующего ScrollTrigger:\n\n```js\nonUpdate: (self) => {\n  if (this.dataset.mcDirection === 'ltr') {\n    this.timeline.timeScale(-1);\n  } else if (this.dataset.mcDirection === 'auto') {\n    this.timeline.timeScale(self.direction);\n  }\n},\n```\n\n`self.direction` возвращает `1` при движении вперёд и `-1` при движении назад. Отдельно хранить предыдущий `scrollY`, сравнивать значения и обслуживать глобальный обработчик больше не нужно.\n\nЭто тот случай, когда оптимизация состоит не в ускорении кода, а в его удалении. Событие уже обработано библиотекой, которой пользуется компонент. Второй параллельный механизм только создавал ещё одно состояние, которое нужно синхронизировать и очищать.\n\n## Очистка в одном месте\n\nРабота с анимацией переехала в отдельный метод:\n\n```js\nclearTimeline() {\n  if (this.timeline) {\n    this.timeline.kill();\n    this.timeline = null;\n  }\n\n  this.gsap.set(this.children, { clearProps: true });\n}\n```\n\nТеперь одна и та же очистка используется перед пересозданием анимации, при выходе из медиазапроса и при отключении компонента.\n\n`disconnectedCallback()` тоже стал полнее:\n\n```js\ndisconnectedCallback() {\n  cancelAnimationFrame(this.af);\n  this.clearTimeline();\n\n  if (this.resizeObserver) {\n    this.resizeObserver.disconnect();\n  }\n}\n```\n\n`ResizeObserver.disconnect()` прекращает наблюдение за всеми связанными элементами. Для Custom Element это не дополнительная перестраховка, а нормальная половина жизненного цикла: всё, что создаётся при инициализации, должно иметь понятный путь остановки.\n\n## Клоны создаются пакетом\n\nФормула количества клонов не изменилась:\n\n```js\nconst requiredQuantity = Math.ceil(\n  this.scrollWidth \u002F this.firstElementChild.clientWidth + 2,\n);\n```\n\nИзменился способ добавления. Раньше каждый клон сразу вставлялся в DOM внутри цикла. Теперь сначала создаётся массив, а затем все элементы передаются в один `append()`:\n\n```js\nconst clones = Array.from(\n  { length: requiredQuantity - 1 },\n  () => this.firstElementChild.cloneNode(true),\n);\n\nthis.append(...clones);\n```\n\nЭто не повод обещать магический прирост производительности. Клоны всё равно нужно создать, а браузеры умеют оптимизировать последовательные операции. Но код выполняет одну операцию добавления и проще отделяет подготовку узлов от изменения DOM.\n\n## Жизненный цикл ещё не закрыт\n\nВерсия 3.0 закрыла заметную часть долга.\n\n`ResizeObserver` создаётся и начинает наблюдение в конструкторе. После `disconnect()` повторное подключение того же DOM-элемента не запускает `observe()` заново. Экземпляр `gsap.matchMedia()` тоже не получает общего `revert()` в `disconnectedCallback()`.\n\nТо есть очистка стала лучше, но сценарий remove → append всё ещё требует внимания. Исправление утечки легко создаёт новый крайний случай, если думать только об удалении и забыть о повторном подключении.\n\n## Материалы\n\n- [Документация GSAP ScrollTrigger](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F)\n- [Свойство ScrollTrigger.direction](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002Fdirection\u002F)\n- [MDN: ResizeObserver](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver)\n- [MDN: ResizeObserver.disconnect()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\u002Fdisconnect)\n- [MDN: requestAnimationFrame()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWindow\u002FrequestAnimationFrame)\n","beskonechnaya-animacziya-konechnyj-zhiznennyj-czikl","2023-10-31","2026-09-29T13:32:02.013Z",[],{"id":158,"documentId":159,"title":160,"content":161,"slug":162,"author":10,"displayDate":163,"publishedAt":164,"tags":165},70,"n9v8botqj54tx61u0jahrprs","Бегущая строка за четыре дня","Бегущая строка выглядит почти оскорбительно простой задачей — положить элементы в ряд, сдвигать их влево или вправо и повторять всё сначала. Примерно так я и начал эксперимент с GSAP, ScrollTrigger и Custom Elements.\n\nЧерез четыре дня в коде уже были клонирование содержимого, три направления движения, адаптивные границы, пауза за пределами экрана и отдельная логика для изменения размеров на мобильных. Самой простой частью всё ещё оставалось само движение.\n\nСегодня опубликовал `marquee-content@1.0.0`. Разберу, что вошло в первую стабильную версию, какие решения сработали и где небольшой эксперимент уже успел обзавестись вполне взрослым тех. долгом.\n\n## Минимальная оболочка Custom Element\n\nЯ хотел, чтобы компонент подключался без отдельной разметки из служебных обёрток. Пользователь описывает содержимое, а библиотека занимается движением:\n\n```html\n\u003Cmarquee-content\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n  role=\"marquee\"\n>\n  \u003Cul>\n    \u003Cli>Primary\u003C\u002Fli>\n    \u003Cli>Secondary\u003C\u002Fli>\n    \u003Cli>Tertiary\u003C\u002Fli>\n  \u003C\u002Ful>\n\u003C\u002Fmarquee-content>\n```\n\nКомпонент — автономный Custom Element:\n\n```js\nexport class MarqueeContent extends HTMLElement {\n  constructor() {\n    super();\n\n    this.mm = gsap.matchMedia();\n    this.tl = gsap.timeline();\n    \u002F\u002F Инициализация параметров и анимации\n  }\n}\n\ncustomElements.get('marquee-content')\n  || customElements.define('marquee-content', MarqueeContent);\n```\n\nПроверка через `customElements.get()` не даёт повторно зарегистрировать элемент при повторном подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM: стили страницы видят содержимое и могут управлять им напрямую.\n\nУ этого решения есть шероховатость. Текущая версия читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до `connectedCallback()`. Демо работает, потому что скрипт регистрирует элемент после разбора разметки, но при более раннем подключении инициализация может получить ещё пустой элемент.\n\nЕсть и интеграционный долг — демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет `gsap` зависимостью, но исходный модуль всё равно ждёт глобальные `gsap` и `ScrollTrigger`. Версия стабильная, способ подключения — не вполне.\n\n## Откуда берётся бесконечность\n\nОдной копии содержимого недостаточно. Когда она уедет за левую границу, справа появится пустое место. Поэтому компонент сначала вычисляет, сколько копий нужно для заполнения контейнера:\n\n```js\nlet requiredQuantity = (\n  this.clientWidth \u002F this.firstElementChild.clientWidth + 3\n).toFixed(0);\n\nfor (let i = 1; i \u003C requiredQuantity; i++) {\n  const item = this.firstElementChild;\n  const clone = item.cloneNode(true);\n  item.parentNode.append(clone);\n}\n```\n\nВ более компактной записи расчёт выглядит так:\n\n```js\nN = round(W \u002F w + 3)\n```\n\nгде `W` — ширина контейнера, `w` — ширина исходного блока, а `N` — итоговое количество блоков вместе с оригиналом.\n\nНапример, для контейнера шириной `960px` и блока шириной `320px` получается:\n\n```js\nN = round(960 \u002F 320 + 3) = 6\n```\n\nТри блока закрывают видимую ширину, ещё три дают избыточное покрытие во время циклического сдвига. Формула не ищет минимум, а сознательно создаёт лишние копии.\n\nПосле клонирования GSAP создаёт одну временную шкалу анимации для всех дочерних элементов:\n\n```js\nthis.tl.to(this.children, {\n  duration: this.duration,\n  x: '-100%',\n  ease: 'none',\n  repeat: -1,\n});\n```\n\nКаждая копия смещается на собственную ширину. Линейная функция плавности (`ease: 'none'`) и бесконечное повторение (`repeat: -1`) создают непрерывный цикл, а одинаковые копии скрывают переход.\n\n## Направление без второй анимации\n\nДля `rtl` и `ltr` не нужны две отдельные анимации. Достаточно менять знак `timeScale`:\n\n```js\nthis.tl\n  .to(this.children, {\n    duration: this.duration,\n    x: '-100%',\n    ease: 'none',\n    repeat: -1,\n  })\n  .timeScale(this.dir === 'ltr' ? -1 : 1)\n  .totalProgress(0.5);\n```\n\nПоложительный масштаб времени проигрывает анимацию вперёд, отрицательный — назад. `totalProgress(0.5)` помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации, чтобы отрицательный `timeScale` не остановился сразу на абсолютном начале.\n\nТретье значение, `auto`, связывает направление с прокруткой страницы. Обработчик сравнивает текущий `pageYOffset` с предыдущим и плавно переводит `timeScale` в `1` или `-1`:\n\n```js\nconst orientation = window.pageYOffset > currentScroll ? 1 : -1;\n\nif (orientation !== scrollDirection) {\n  gsap.to(this.tl, {\n    timeScale: orientation,\n    overwrite: true,\n  });\n}\n```\n\nScrollTrigger решает другую задачу: ставит анимацию на паузу, когда компонент покидает область просмотра (`viewport`), и возобновляет при возвращении. Невидимая анимация продолжала бы расходовать ресурсы без пользы.\n\n## Адаптивность через gsap.matchMedia()\n\nКомпонент поддерживает `data-mc-min` и `data-mc-max` по отдельности. Каждый из них превращается в медиазапрос, внутри которого создаются клоны и анимация. Если указать оба, `min` перезапишет запрос для `max`, поэтому полноценного диапазона из двух границ пока нет.\n\nДля этого используется `gsap.matchMedia()` из GSAP 3.11. Метод запускает переданную функцию при совпадении запроса, а при выходе откатывает созданные GSAP-анимации и вызывает функцию очистки:\n\n```js\nthis.mm.add(this.breakpoint, () => {\n  \u002F\u002F Создание клонов и анимации\n\n  return () => {\n    removingClones();\n  };\n});\n```\n\nЭто оказалось удобнее отдельного набора `matchMedia().addEventListener()` и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.\n\nGSAP автоматически откатывает созданные внутри функции анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики `scroll`, `resize` и `change` не снимаются, поэтому очистка жизненного цикла остаётся неполной.\n\n## Неприятные сюрпризы\n\nПервый неприятный сюрприз пришёл от iOS. Изменение видимой области браузера во время прокрутки генерировало `resize`. Обработчик безусловно пересобирал анимацию и заново считал клоны, даже когда ширина не менялась. Повторная инициализация во время прокрутки могла нарушить непрерывность ленты.\n\nПосле пары экспериментов с уверенностью, что быстро решу проблему, последовала серия из тестовых коммитов.\n\nПроверял несколько подходов:\n- Сравнивал новую ширину окна с предыдущей;\n- Пробовал отделять мобильные устройства через `userAgent`;\n- Слушал изменение ориентации;\n- Менял задержку debounce;\n- Полностью останавливал анимацию перед повторным клонированием.\n\nВ текущей версии обработчики подключаются через два медиазапроса:\n\n```js\nthis.mm.add('(any-pointer: coarse)', () => {\n  const portrait = window.matchMedia('(orientation: portrait)');\n\n  portrait.addEventListener('change', (event) => {\n    if (!event.matches) {\n      resetAmin();\n    }\n  });\n});\n\nthis.mm.add('(any-pointer: fine)', () => {\n  window.addEventListener(\n    'resize',\n    this.debounce(resetAmin, 250),\n  );\n});\n```\n\n`(any-pointer: coarse)` включает обработку смены ориентации, а `(any-pointer: fine)` — обычный `resize` с задержкой `250ms`. Запросы не взаимоисключающие — на гибридном устройстве могут сработать оба.\n\nВетвь `coarse` тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. Но это устраняет конкретный сбой, не заставляя тяжёлую пересборку срабатывать вслед за движением браузерной панели на сенсорном устройстве.\n\n## API версии 1.0\n\nВ первый стабильный API вошли пять атрибутов:\n\n- `data-mc-duration` — длительность одного цикла, то есть сдвига на ширину блока, в секундах — по умолчанию `20`;\n- `data-mc-direction` — `rtl`, `ltr` или `auto`;\n- `data-mc-skew` — наклон по оси Y;\n- `data-mc-min` — минимальная ширина для запуска;\n- `data-mc-max` — максимальная ширина для запуска.\n\n`data-mc-min` и `data-mc-max` работают как альтернативы, а не как совместный диапазон.\n\nЗа четыре дня у компонента успело появиться больше обязанностей, чем предполагала исходная идея. Само бесконечное движение занимает несколько строк, а основная работа оказалась вокруг геометрии, изменения размеров и жизненного цикла Custom Element.\n\n## Материалы\n\n- [GSAP 3.11: gsap.matchMedia()](https:\u002F\u002Fgsap.com\u002Fblog\u002F3-11\u002F)\n- [Документация GSAP ScrollTrigger](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F)\n- [Документация GSAP totalProgress()](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FGSAP\u002FTimeline\u002FtotalProgress()\u002F)\n- [HTML Standard: Custom Elements](https:\u002F\u002Fhtml.spec.whatwg.org\u002Fmultipage\u002Fcustom-elements.html)\n- [Media Queries Level 4: any-pointer](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fmediaqueries-4\u002F#any-input)\n","begushhaya-stroka-za-chetyre-dnya-pervyj-stabilnyj-reliz-marquee-content","2023-03-24","2026-09-29T13:31:51.769Z",[],{"id":60,"documentId":61,"title":62,"content":63,"slug":64,"author":10,"displayDate":65,"publishedAt":66,"tags":167,"html":168},[],"\u003Cp class=\"body-large\">В HopperGame звук перестал быть разовым эффектом, который можно включить и забыть.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Один звук должен идти циклически, пока персонаж находится в определённом состоянии. Другой при повторном \u003Ccode>play()\u003C\u002Fcode> можно наложить поверх предыдущего. Третий нужно прервать или вообще не запускать повторно, пока он ещё звучит.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Здесь же снова проявилась особенность iOS. После возврата в Safari одного \u003Ccode>AudioContext.state\u003C\u002Fcode> оказалось недостаточно: контекст может сообщать \u003Ccode>running\u003C\u002Fcode>, хотя реального звука уже нет.\u003C\u002Fp>\n\u003Cp class=\"body-large\">С моделью, где каждый \u003Ccode>ClickTone\u003C\u002Fcode> сам владеет своим \u003Ccode>AudioContext\u003C\u002Fcode>, разбирать всё это по экземплярам стало неудобно.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Общую часть вынести в движок\u003C\u002Fh2>\n\u003Cp class=\"body-large\">До \u003Ccode>2.0.0\u003C\u002Fcode> у каждого звука был свой контекст и свой кеш декодированных файлов. Для простого \u003Ccode>play()\u003C\u002Fcode> это нормально, но разблокировка и восстановление после возврата на страницу относятся уже не к конкретному эффекту. Это состояние всей аудиосистемы страницы.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Поэтому появился \u003Ccode>SharedAudioEngine\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> SharedAudioEngine\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  #ctx\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> AudioContext\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> |\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> null\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> null\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  #decodeCache\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> Map\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">string\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, \u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">Promise\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">AudioBuffer\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>>();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  \u002F\u002F ...\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> engine\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> SharedAudioEngine\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Экземпляры ClickTone теперь обращаются к нему за общими вещами:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">engine.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">prime\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> buffer\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> await\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> engine.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">decode\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(url);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> ctx\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> engine.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">context\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Кеш тоже стал общим. Если два звука используют один URL, загружать и декодировать один файл дважды смысла нет.\u003C\u002Fp>\n\u003Cp class=\"body-large\">При этом настройки конкретного эффекта остались в \u003Ccode>ClickTone\u003C\u002Fcode>: \u003Ccode>volume\u003C\u002Fcode>, \u003Ccode>mute\u003C\u002Fcode>, \u003Ccode>throttle\u003C\u002Fcode>, \u003Ccode>pitch\u003C\u002Fcode> и собственный \u003Ccode>GainNode\u003C\u002Fcode>. Движок занимается только тем, что действительно должно жить на уровне страницы.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Одна разблокировка вместо обработчиков на каждом звуке\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Движок устанавливает общий набор обработчиков пользовательских жестов и изменений состояния страницы:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">prime\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(): \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">void\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#primed \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">||\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> !\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">hasDOM) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">return\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#primed \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">#installGestureUnlock\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">#installVisibilityHandler\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Первый подходящий жест вызывает \u003Ccode>unlock()\u003C\u002Fcode>. Если контекст приостановлен, движок вызывает \u003Ccode>resume()\u003C\u002Fcode>. После успешной разблокировки дополнительно запускается почти бесшумный односэмпловый буфер, чтобы помочь Web Audio окончательно проснуться.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Главное здесь не сам трюк с буфером, а то, что вопрос разблокировки решается один раз для всей страницы. Каждому звуку больше не нужны свои обработчики касаний и собственное представление о том, готов ли Web Audio.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Если AudioContext только выглядит рабочим\u003C\u002Fh2>\n\u003Cp class=\"body-large\">После \u003Ccode>2.0.0\u003C\u002Fcode> нашёлся неприятный кейс Safari\u002FiOS: после ухода из браузера и возврата обратно \u003Ccode>AudioContext\u003C\u002Fcode> может остаться в \u003Ccode>state === 'running'\u003C\u002Fcode>, но перестать реально воспроизводить звук.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Поэтому в \u003Ccode>2.1.0\u003C\u002Fcode> проверяю ещё и \u003Ccode>currentTime\u003C\u002Fcode>. После возврата на страницу движок запоминает значение, ждёт \u003Ccode>200ms\u003C\u002Fcode> и смотрит, продвинулось ли аудиовремя:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> start\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> ctx.currentTime;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">await\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> Promise\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">((\u003C\u002Fspan>\u003Cspan style=\"color:#FFAB70\">resolve\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  setTimeout\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(resolve, SharedAudioEngine.\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">#ZOMBIE_PROBE_MS\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">),\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (ctx.state \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'running'\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> &#x26;&#x26;\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> ctx.currentTime \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">&#x3C;=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> start) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#needsHardRecovery \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Если состояние говорит \u003Ccode>running\u003C\u002Fcode>, а \u003Ccode>currentTime\u003C\u002Fcode> стоит на месте, обычного \u003Ccode>resume()\u003C\u002Fcode> уже недостаточно.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Сразу пересоздавать контекст на \u003Ccode>visibilitychange\u003C\u002Fcode> я не стал. Полное восстановление откладывается до следующего пользовательского действия:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#needsHardRecovery) \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">#recreate\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Так тяжёлый путь включается только после проверки и в момент, когда браузер с большей вероятностью разрешит снова поднять аудио.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">После пересоздания нужно восстановить аудиограф\u003C\u002Fh2>\n\u003Cp class=\"body-large\">\u003Ccode>new AudioContext()\u003C\u002Fcode> сам по себе проблему не заканчивает. \u003Ccode>GainNode\u003C\u002Fcode> и активные \u003Ccode>AudioBufferSourceNode\u003C\u002Fcode> были созданы в старом контексте и вместе с ним становятся бесполезны.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Поэтому ClickTone подписывается на пересоздание контекста:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#recreateUnsubscribe \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> engine.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">onRecreate\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(() \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">#restartActiveLoops\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(),\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">\u003Ccode>GainNode\u003C\u002Fcode> пересоздаётся, если относится уже не к текущему контексту. Активные циклические воспроизведения собираются по URL и запускаются заново через новый движок.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Именно на этом месте стало понятно, что циклическое воспроизведение нельзя считать тем же коротким звуком, только с \u003Ccode>source.loop = true\u003C\u002Fcode>. Если звук живёт дольше одного вызова, библиотеке приходится помнить о нём.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Хранить активное воспроизведение\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В \u003Ccode>2.1.0\u003C\u002Fcode> экземпляр начал отслеживать созданные источники звука:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">type\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> ActivePlayback\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  source\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> AudioBufferSourceNode\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  url\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> string\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  loop\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> boolean\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  stopped\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> boolean\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">#activePlaybacks \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> Set\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">ActivePlayback\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">При запуске источник добавляется в набор:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">source.buffer \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> buffer;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">source.loop \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> loop;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> playback\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> ActivePlayback\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  source,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  url,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  loop,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  stopped: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">false\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#activePlaybacks.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(playback);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">source.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">start\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">0\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">После этого у \u003Ccode>stop()\u003C\u002Fcode> появляется конкретный объект, который можно завершить:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">stop\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(): \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">void\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">#stopActivePlaybacks\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Для HopperGame это как раз тот API, которого не хватало:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> flying\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> ClickTone\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">({\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  src: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'.\u002Fflying.mp3'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  loop: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  preload: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">await\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> flying.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">play\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">\u002F\u002F состояние закончилось\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">flying.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">stop\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Длительность эффекта здесь задаёт уже состояние игры, а не длина аудиофайла.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Что означает повторный play()\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Для короткого звука клика наложение обычно нормально. У игрового эффекта повторный вызов может означать совсем другое, поэтому в \u003Ccode>2.1.0\u003C\u002Fcode> это стало явной настройкой:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">type\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> ReplayBehavior\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'overlap'\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'interrupt'\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'ignore-if-playing'\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'restart'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">\u003Ccode>overlap\u003C\u002Fcode> оставляет прежнее поведение и создаёт новое воспроизведение. \u003Ccode>interrupt\u003C\u002Fcode> останавливает активный и запускает новый. \u003Ccode>ignore-if-playing\u003C\u002Fcode> ничего не делает, пока звук уже идёт. \u003Ccode>restart\u003C\u002Fcode> обязательно начинает воспроизведение заново.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для \u003Ccode>restart\u003C\u002Fcode> обычный \u003Ccode>throttle\u003C\u002Fcode> пришлось обходить:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  !\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">skipThrottle \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">&#x26;&#x26;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  replay \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">!==\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'restart'\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> &#x26;&#x26;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  now \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#lastPlay \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.#throttle\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Иначе явная команда перезапустить звук могла бы потеряться из-за ограничения, которое решает совсем другую задачу.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Поведение при повторном \u003Ccode>play()\u003C\u002Fcode> можно задать на экземпляре:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> terminal\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> ClickTone\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">({\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  src: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'.\u002Fterminal.mp3'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  replay: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'interrupt'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">await\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> terminal.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">play\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch2 class=\"headline-medium\">Что возвращает play() для loop\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Для обычного звука \u003Ccode>play()\u003C\u002Fcode> возвращает \u003Ccode>Promise\u003C\u002Fcode>, который завершается после \u003Ccode>source.onended\u003C\u002Fcode>. У циклического воспроизведения естественного конца может не быть вообще.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Поэтому циклическое воспроизведение считается запущенным сразу после \u003Ccode>source.start(0)\u003C\u002Fcode>, а ожидание \u003Ccode>onended\u003C\u002Fcode> остаётся только для обычного эффекта:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">source.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">start\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">0\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">#emit\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'play'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">!\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">loop) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">await\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> ended;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Заодно \u003Ccode>stop\u003C\u002Fcode> получил отдельное событие. Естественное завершение даёт \u003Ccode>end\u003C\u002Fcode>, явное прерывание — \u003Ccode>stop\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В результате короткий UI-звук по-прежнему запускается обычным \u003Ccode>play()\u003C\u002Fcode>. Но если эффект связан с состоянием игры, его теперь можно зациклить, остановить и заранее определить, что должен означать повторный вызов. А восстановление Web Audio больше не размазано по экземплярам и живёт рядом с общим \u003Ccode>AudioContext\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Материалы\u003C\u002Fh2>\n\u003Cul class=\"list-large\">\n\u003Cli>MDN, Web Audio best practices: \u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_Audio_API\u002FBest_practices\">https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_Audio_API\u002FBest_practices\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>MDN, \u003Ccode>AudioContext.resume()\u003C\u002Fcode>: \u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAudioContext\u002Fresume\">https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAudioContext\u002Fresume\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>MDN, \u003Ccode>BaseAudioContext.currentTime\u003C\u002Fcode>: \u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FBaseAudioContext\u002FcurrentTime\">https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FBaseAudioContext\u002FcurrentTime\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>WebKit Bug 217606: \u003Ca href=\"https:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=217606\">https:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=217606\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>WebKit Bug 263627: \u003Ca href=\"https:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=263627\">https:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=263627\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>WebKit Bug 276687: \u003Ca href=\"https:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=276687\">https:\u002F\u002Fbugs.webkit.org\u002Fshow_bug.cgi?id=276687\u003C\u002Fa>\u003C\u002Fli>\n\u003C\u002Ful>\n",1790690444027]