[{"data":1,"prerenderedAt":187},["ShallowReactive",2],{"journal-all":3,"journal-post-begushhaya-stroka-za-chetyre-dnya":184},[4,14,23,32,41,50,59,68,77,86,95,104,113,122,131,140,149,157,166,175],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":13},100,"ejmfhc9x77pq7i6lk8uri7vz","Хорошие платформеры прощают маленькие ошибки игрока","Игрок нажал кнопку прыжка у самого края платформы, а персонаж вместо прыжка падает вниз. Или нажал прыжок чуть раньше приземления, и прыжок не сработал. Персонаж погибает, хотя опасный объект задел его буквально одним пикселем.\n\nФормально игра во всех этих случаях может быть права. Проблема в том, что игроку от этого не легче.\n\nЧтобы управление ощущалось отзывчивым, разработчики дают игроку больше контроля над прыжком и немного увеличивают допустимые окна нажатий.\n\n## Управление прыжком\n\n### Переменная высота прыжка\n\nСамый простой прыжок работает, если игрок нажал кнопку, пока персонаж стоит на земле:\n\n```ts\nif (jumpPressed && grounded) {\n    velocityY = -jumpSpeed;\n}\n```\n\nВ примере координата Y увеличивается вниз, поэтому отрицательная скорость двигает персонажа вверх.\n\nНажали кнопку — получили вертикальную скорость. Всё работает, но высота прыжка всегда одна и та же. Игрок выбирает момент старта, а саму дугу изменить уже не может.\n\nГораздо удобнее, когда короткое нажатие даёт небольшой прыжок, а удержание кнопки — высокий. Один из самых простых способов — уменьшить вертикальную скорость при отпускании кнопки:\n\n```ts\nif (jumpReleased && velocityY \u003C 0) {\n    velocityY *= 0.5;\n}\n```\n\nЕсли отпустить кнопку во время подъёма, скорость уменьшится, персонаж раньше достигнет вершины и прыжок получится короче. Коэффициент `0.5` определяет, насколько резко обрезается подъём: при слишком маленьком значении персонаж будет почти сразу останавливаться в воздухе.\n\nДругой вариант — увеличивать гравитацию после отпускания кнопки:\n\n```ts\nlet gravityScale = 1;\n\nif (velocityY \u003C 0 && !jumpHeld) {\n    gravityScale = 2;\n}\n\nvelocityY += gravity * gravityScale * dt;\n```\n\nЗдесь `dt` — время между обновлениями в секундах. Скорость меняется постепенно, поэтому можно получить более плавный короткий прыжок.\n\n:::video\nwebm: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fvariable_jump_height_e93ef9f608.webm\nmp4: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fvariable_jump_height_7cc9228aed.mp4\nposter: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fvariable_jump_height_3d2ad6532b.webp\n:::\n\n### Важна не только максимальная высота\n\n`jumpSpeed` задаёт начальную скорость подъёма, `gravity` — насколько быстро меняется вертикальная скорость, а коэффициент обрезания или гравитация после отпускания кнопки — разницу между коротким и длинным прыжком. Скорость падения ограничивает `maxFallSpeed`.\n\nВ опубликованном [Player.cs](https:\u002F\u002Fgithub.com\u002FNoelFB\u002FCeleste\u002Fblob\u002Fmaster\u002FSource\u002FPlayer\u002FPlayer.cs) Celeste для прыжка тоже отдельно заданы начальная скорость и параметры гравитации. Ещё одна настройка задаёт, как долго удержание кнопки поддерживает скорость подъёма.\n\nЕсли увеличить `jumpSpeed`, не меняя гравитацию, прыжок станет выше и дольше. Персонаж допрыгивает до нужной высоты, но слишком долго возвращается на землю. Если просто уменьшать гравитацию, замедлятся и подъём, и падение, а прыжок станет ватным.\n\nПоэтому эти части траектории часто настраивают отдельно. Например, при падении применяют более сильную гравитацию, чтобы персонаж быстрее возвращался на платформу. Проверять результат удобно на двух соседних препятствиях: низком проходе для короткого прыжка и высокой платформе для максимального. Оба должны проходиться уверенно одной и той же кнопкой.\n\n### Ослабление гравитации у вершины прыжка\n\nУ вершины дуги гравитацию можно ненадолго уменьшить, пока вертикальная скорость близка к нулю и игрок держит кнопку:\n\n```ts\nconst nearApex = Math.abs(velocityY) \u003C apexThreshold;\nconst gravityScale = nearApex && jumpHeld ? 0.5 : 1;\n\nvelocityY += gravity * gravityScale * dt;\n```\n\nПерсонаж проводит около вершины немного больше времени. Игрок успевает поправить горизонтальное положение перед приземлением, особенно на небольшой платформе.\n\nВ Celeste при удержании кнопки около вершины применяется половинная гравитация. Мэдди Торсон описывает этот приём в [Celeste & Forgiveness](https:\u002F\u002Fwww.mattmakesgames.com\u002Farticles\u002Fceleste_and_forgiveness\u002Findex.html).\n\nЕсли `apexThreshold` слишком велик, замедление затронет заметную часть подъёма и падения: вместо небольшой помощи получится зависание. Если высота прыжка зависит от удержания кнопки, отпускание должно прекращать замедление.\n\n:::video\nwebm: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Freduced_gravity_at_jump_apex_09d650b0a9.webm\nmp4: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Freduced_gravity_at_jump_apex_3c8c23b2ac.mp4\nposter: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Freduced_gravity_at_jump_apex_ab1b700cca.webp\n:::\n\nНо даже хорошо настроенная траектория не поможет, если игра отвергает нажатие из-за одного кадра разницы.\n\n## Прощение ошибок во времени\n\n### Coyote time\n\nПроверка `jumpPressed && grounded` разрешает прыжок, только пока под персонажем есть опора. Если игрок нажал кнопку через один кадр после схода с края, `grounded` уже будет `false`.\n\nПри быстром движении такой отказ особенно заметен: игрок только что видел персонажа на платформе и рассчитывал успеть. Coyote time позволяет прыгнуть ещё некоторое время после схода с края:\n\n```ts\nif (grounded) {\n    coyoteTimer = coyoteTime;\n} else {\n    coyoteTimer = Math.max(0, coyoteTimer - dt);\n}\n\nif (jumpPressed && coyoteTimer > 0) {\n    velocityY = -jumpSpeed;\n    grounded = false;\n    coyoteTimer = 0;\n}\n```\n\nНазвание отсылает к мультяшному койоту, который успевает немного пробежать по воздуху, прежде чем замечает, что под ним нет земли.\n\nПосле успешного прыжка таймер обязательно сбрасывается. Иначе следующее нажатие до его истечения может дать второй прыжок в воздухе. При этом `grounded` нужно сбросить, чтобы устаревшее состояние не открыло окно заново.\n\nВ опубликованном [коде Celeste](https:\u002F\u002Fgithub.com\u002FNoelFB\u002FCeleste\u002Fblob\u002Fmaster\u002FSource\u002FPlayer\u002FPlayer.cs) это окно называется `JumpGraceTime` и составляет `0.1` секунды. При прыжке `jumpGraceTimer` обнуляется.\n\nДля настройки полезно перевести время в расстояние. При постоянной горизонтальной скорости:\n\n```text\nрасстояние после края = скорость × длительность coyote time\n```\n\nПри `400 px\u002Fs` окно в `0.1 s` разрешает прыгнуть примерно в 40 пикселях от точки схода. Если герой успевает далеко отлететь от платформы, такая помощь уже будет заметна. Поэтому проверять окно стоит и на обычной, и на максимальной скорости.\n\n:::video\nwebm: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fcoyote_time_a4b88b342b.webm\nmp4: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fcoyote_time_d855836514.mp4\nposter: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fcoyote_time_4de6d8d492.webp\n:::\n\n### Буфер ввода\n\nCoyote time помогает при позднем нажатии. Есть и обратная ситуация: игрок нажал прыжок немного раньше приземления.\n\nВ момент нажатия персонаж ещё в воздухе, поэтому прыгать нельзя. На следующем кадре он уже стоит на платформе, но событие нажатия прошло. Если команда нигде не сохраняется, игроку придётся нажать ещё раз.\n\nБуфер ввода ненадолго сохраняет нажатие и выполняет команду, когда действие становится доступным:\n\n```ts\nif (jumpPressed) {\n    jumpBufferTimer = jumpBufferTime;\n} else {\n    jumpBufferTimer = Math.max(0, jumpBufferTimer - dt);\n}\n\nif (jumpBufferTimer > 0 && canJump) {\n    jump();\n    jumpBufferTimer = 0;\n}\n```\n\nЗдесь `canJump` означает, что персонаж стоит на земле или ещё не истекло окно coyote time. Если персонаж приземлится до истечения таймера, сохранённый прыжок сработает. После выполнения команда удаляется.\n\nБуфер запускается по событию `jumpPressed`, а не по удержанию `jumpHeld`. Иначе удерживаемая кнопка будет постоянно обновлять таймер, и персонаж начнёт автоматически прыгать после каждого приземления. Такое поведение можно сделать намеренно, но оно требует отдельного решения.\n\nПри переменной высоте прыжка есть ещё одна тонкость: игрок может отпустить кнопку до выполнения сохранённого прыжка. Здесь нужно решить, будет ли это короткий прыжок или отпускание отменит команду. Проверка только текущего `jumpReleased` не увидит уже прошедшее событие.\n\nТот же буфер подходит для атаки, рывка или переката. Например, нажатие во время завершения предыдущей анимации может дождаться первого доступного момента. В небольшой игре достаточно отдельных таймеров — общая очередь команд понадобится, если появятся правила приоритета и замены действий.\n\n:::video\nwebm: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Finput_buffering_265b4a1223.webm\nmp4: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Finput_buffering_1ca2773034.mp4\nposter: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Finput_buffering_5867479c81.webp\n:::\n\n### Coyote time и буфер работают вместе\n\nCoyote time ненадолго сохраняет право на прыжок после схода с платформы, а буфер — нажатие перед приземлением.\n\nПосле обновления обоих таймеров проверяем, осталось ли право на прыжок и сохранено ли нажатие:\n\n```ts\nif (coyoteTimer > 0 && jumpBufferTimer > 0) {\n    velocityY = -jumpSpeed;\n    grounded = false;\n\n    coyoteTimer = 0;\n    jumpBufferTimer = 0;\n}\n```\n\nПри приземлении окно coyote time снова открывается, и сохранённое нажатие может запустить прыжок. При сходе с края ещё не истёкший таймер позволяет выполнить новый прыжок в воздухе.\n\n## Импульс движущихся платформ\n\nПредставим платформу, которая быстро едет вправо. Персонаж стоит на ней, перемещается вместе с ней и прыгает. Если после отрыва его горизонтальная скорость внезапно перестаёт учитывать движение платформы, траектория может выглядеть неожиданно.\n\nПри прыжке можно передать персонажу скорость опоры. Если `velocityX` уже включает скорость платформы, прибавлять её повторно нельзя. В примере ниже платформа переносит персонажа отдельно, а его собственная скорость хранится в `velocityX` и `velocityY`.\n\nМожно также ненадолго сохранить скорость опоры, чтобы прыжок получил ту же добавку даже сразу после её остановки:\n\n```ts\nliftMomentumTimer = Math.max(0, liftMomentumTimer - dt);\n\nif (grounded && movingPlatform) {\n    const vx = movingPlatform.velocityX;\n    const vy = movingPlatform.velocityY;\n    const platformIsMoving =\n        Math.abs(vx) > liftSpeedEpsilon ||\n        Math.abs(vy) > liftSpeedEpsilon;\n\n    if (platformIsMoving) {\n        liftVelocityX = vx;\n        liftVelocityY = vy;\n        liftMomentumTimer = liftMomentumTime;\n    }\n}\n```\n\nПока персонаж стоит на движущейся платформе, запоминаем её скорость и заново запускаем таймер. После остановки платформы не заменяем сохранённую скорость нулём, а таймер отсчитывает оставшееся время. Небольшой порог `liftSpeedEpsilon` позволяет не считать движением погрешность вычислений.\n\nВ момент прыжка используем сохранённую скорость:\n\n```ts\nvelocityY = -jumpSpeed;\n\nif (liftMomentumTimer > 0) {\n    velocityX += liftVelocityX;\n    velocityY += Math.min(liftVelocityY, 0);\n}\n\nliftMomentumTimer = 0;\n```\n\nВ этом варианте вертикальная добавка учитывается только при движении платформы вверх: она усиливает прыжок, а движение вниз его не ослабляет. В Celeste сохранение скорости после остановки описано как [Lift Momentum Storage](https:\u002F\u002Fwww.mattmakesgames.com\u002Farticles\u002Fceleste_and_forgiveness\u002Findex.html).\n\nСохранённую скорость привязывают к конкретной платформе и сбрасывают при смене опоры. Проверять настройку удобно прыжками непосредственно до и после остановки. Если усиление остаётся доступным спустя заметную паузу, время хранения слишком велико.\n\n:::video\nwebm: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fmoving_platform_momentum_8bb4add87e.webm\nmp4: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fmoving_platform_momentum_c46c78bdec.mp4\nposter: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fmoving_platform_momentum_246ffc6bfd.webp\n:::\n\n## Прощающая геометрия\n\n### Collider, hurtbox и hitbox\n\nЧастая ошибка — использовать одну геометрию для всех взаимодействий персонажа. Есть спрайт персонажа размером `32×48`, значит, берём прямоугольник `32×48` и проверяем им стены, шипы и атаки.\n\nНо у спрайта могут быть волосы, плащ, оружие и анимированные конечности. Если каждая выступающая деталь участвует в столкновениях, игрок начинает получать урон там, где по картинке почти увернулся.\n\nУдобнее разделить области по назначению:\n\n| Область | За что отвечает |\n| --- | --- |\n| Collider | Физическое взаимодействие с землёй, стенами и платформами |\n| Hurtbox | Получение урона персонажем |\n| Hitbox атаки | Область, которой оружие, снаряд или противник наносит удар |\n\nCollider должен задавать предсказуемую форму персонажа. Прямоугольник или капсула часто удобнее точного контура спрайта: анимация рук не должна менять то, как персонаж стоит у стены.\n\nHurtbox можно сделать немного меньше, чтобы пограничное касание опасности не приводило к урону. Тогда атаки проверяются отдельно:\n\n```ts\nif (overlaps(attack.hitbox, player.hurtbox)) {\n    damage(player);\n}\n```\n\nФизическое движение продолжает использовать collider. Так можно простить касание шипа, сохранив привычное поведение у стен и пола.\n\n### Где именно прощать столкновение\n\nМожно уменьшить hurtbox игрока, опасную область шипа или обе области сразу. Настраивать их лучше относительно того, что игрок воспринимает как тело персонажа: одинаковые по размеру спрайты могут сильно отличаться количеством волос, одежды и других деталей.\n\nПоэтому условные \"70% от спрайта\" мало говорят о результате. Гораздо полезнее включить отображение collider, hurtbox и областей атак, а затем покадрово посмотреть пограничные касания.\n\nЕсли персонаж едва задел шип кончиком плаща, отсутствие урона выглядит естественно. Если шип уже глубоко внутри тела, а герой продолжает движение, границы уменьшены слишком сильно. Хорошая проверка: можно ли по остановленному кадру уверенно сказать \"должен ли персонаж получить здесь урон\"?\n\n### Corner correction\n\nПохожая пограничная ситуация возникает с обычной геометрией уровня. Персонаж прыгает рядом с платформой и задевает головой её нижний угол — всего на пару пикселей.\n\nСтолкновение остановит подъём. Corner correction позволяет сначала попробовать небольшой сдвиг в сторону:\n\n```ts\nfunction tryCornerCorrection(maxOffset: number): boolean {\n    for (let offset = 1; offset \u003C= maxOffset; offset++) {\n        if (canSlideAroundCorner(-offset)) {\n            x -= offset;\n            return true;\n        }\n\n        if (canSlideAroundCorner(offset)) {\n            x += offset;\n            return true;\n        }\n    }\n\n    return false;\n}\n```\n\nЗдесь `canSlideAroundCorner()` проверяет, есть ли свободный путь для сдвига в сторону и место для продолжения подъёма. Проверять нужно весь collider, чтобы коррекция не протащила персонажа через соседнюю стену.\n\nЕсли обход найден, персонаж смещается и продолжает движение вверх. Только после неудачной коррекции столкновение обрабатывается как обычный удар о потолок.\n\nВ [Celeste & Forgiveness](https:\u002F\u002Fwww.mattmakesgames.com\u002Farticles\u002Fceleste_and_forgiveness\u002Findex.html) отдельно показаны коррекция угла при прыжке и коррекция при горизонтальном рывке, которая помогает попасть на край платформы.\n\nСдвиг должен быть небольшим относительно размера персонажа. Если для обхода потолка нужно передвинуть героя на половину его ширины, результат уже выглядит как телепортация. Проверять коррекцию стоит у одиночного угла и в узком проходе, где рядом есть вторая стена.\n\n:::video\nwebm: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fcorner_correction_b0e5efd730.webm\nmp4: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fcorner_correction_c7944a27ac.mp4\nposter: https:\u002F\u002Fapi.ux-ui.pro\u002Fuploads\u002Fcorner_correction_5e9b507646.webp\n:::\n\n### Ещё несколько поблажек\n\nПрыжок от стены (wall jump) можно разрешать на небольшом расстоянии от неё, чтобы буквально несколько пикселей не мешали оттолкнуться. При горизонтальном рывке можно слегка поднять персонажа на край платформы, если он почти попал на неё. Обе возможности также описаны в [разборе Celeste](https:\u002F\u002Fwww.mattmakesgames.com\u002Farticles\u002Fceleste_and_forgiveness\u002Findex.html).\n\nДругой вариант — ненадолго сохранять горизонтальную скорость после столкновения со стеной. Например, персонаж прыгает вверх и вправо, задевает боком небольшой выступ и продолжает подниматься. Если он успеет подняться выше выступа, пока сохранённая скорость ещё доступна, движение вправо возобновится с прежней скоростью. В опубликованном [Player.cs](https:\u002F\u002Fgithub.com\u002FNoelFB\u002FCeleste\u002Fblob\u002Fmaster\u002FSource\u002FPlayer\u002FPlayer.cs) для такого поведения есть отдельные поля `wallSpeedRetentionTimer` и `wallSpeedRetained`.\n\n## Порядок обновления тоже имеет значение\n\nЕсли сначала проверить прыжок, а потом определить приземление, сохранённая команда сможет выполниться только при следующем обновлении физики.\n\nНо и переносить все прыжки в конец обновления неудобно: если персонаж уже стоит на земле, нажатие должно влиять на текущее движение. Один из вариантов — проверять сохранённую команду до перемещения, а после столкновений повторить проверку, если персонаж приземлился:\n\n```ts\nfunction update(dt: number): void {\n    readInput();\n    updateJumpBuffer(dt);\n    updateCoyoteTimer(dt);\n    tryConsumeBufferedJump();\n\n    simulateMovement(dt);\n    resolveCollisions();\n    updateGroundedState();\n\n    if (justLanded) {\n        coyoteTimer = coyoteTime;\n        tryConsumeBufferedJump();\n    }\n}\n```\n\n`tryConsumeBufferedJump()` проверяет оба таймера и при успехе сбрасывает их, задаёт скорость прыжка и снимает состояние опоры. Поэтому повторный вызов сам по себе не даёт второго прыжка.\n\nПри таком порядке приземление запускает сохранённый прыжок в том же обновлении физики, но движение вверх начнётся на следующем шаге. Чтобы обработать отрыв за оставшееся время текущего шага, нужно отдельно рассчитать этот участок движения. Конкретный порядок зависит от движка и точности симуляции.\n\nЕсли ввод и физика обновляются в разных циклах, события нажатия нужно сохранять между ними. Иначе короткое нажатие может целиком пройти между двумя физическими обновлениями.\n\n## Настраивать нужно систему целиком\n\nДаже правильно работающие механики могут давать неожиданный результат, если окна прощения слишком велики. Большой буфер может заставить персонажа прыгнуть после нажатия, о котором игрок уже забыл. Длинный coyote time позволит оттолкнуться далеко от края.\n\nЕсть и менее очевидные сочетания. Сильное обрезание прыжка может мешать попасть на маленькую платформу. Слишком широкий диапазон ослабленной гравитации затянет падение. А коррекция угла и переданный импульс вместе могут сдвинуть персонажа дальше, чем ожидается. Если в игре есть подбросы и рывки, правила изменения скорости для них стоит настраивать отдельно: отпускание кнопки прыжка не должно случайно менять их траекторию.\n\nДля проверки удобно собрать небольшую тестовую комнату:\n\n1. край платформы для прыжков на обычной и максимальной скорости;\n2. площадку для нажатий непосредственно до приземления, включая короткое нажатие с отпусканием в воздухе;\n3. низкий проход и высокую платформу для короткого и максимального прыжка;\n4. узкий промежуток между опасностями для проверки hurtbox;\n5. угол потолка и узкий проход для corner correction;\n6. движущуюся платформу с остановкой для прыжков до остановки, сразу после неё и после истечения окна хранения скорости.\n\nПолезно также повторить несколько быстрых прыжков подряд. Так легче заметить неочищенный буфер, повторно открывшееся право на прыжок или случайное повторное использование скорости платформы.\n\nВ одной комнате каждый случай можно воспроизвести десяток раз, меняя по одному параметру. И проверить результат при разной частоте отрисовки: длительность окон, заданных в секундах, не должна меняться из-за FPS.\n\nБольшинство этих приёмов занимает немного кода. Основная работа — подобрать границы помощи так, чтобы небольшие неточности не срывали ожидаемое действие, а движение оставалось понятным. Прыжок у самого края срабатывает, раннее нажатие дожидается земли, едва задетый угол не обрывает подъём. Когда всё настроено удачно, игрок просто двигается так, как рассчитывал.","horoshie-platformery-proshhayut-malenkie-oshibki-igroka","Maxim","2026-10-08","2026-10-09T15:22:43.330Z",[],{"id":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":22},98,"cpt6fex3mcb2pk2rwxrqlvl0","Что может локальный coding-agent на 16 GB VRAM","Обычно в работе с кодом я использую frontier-модели. Для разработки у меня достаточно мощный ПК, но специально под запуск локальных моделей я его не собирал:\n\n```text\nGPU: NVIDIA GeForce RTX 4070 Ti SUPER, 16 GB VRAM\nRAM: 64 GB DDR5-6400\nOS: Windows\n```\n\nСтало интересно, насколько далеко можно зайти на таком железе с современной локальной моделью. Не просто запустить чат и попросить написать функцию, а поработать с агентом, который читает настоящий проект, соблюдает правила репозитория и способен найти реальную проблему в коде.\n\nДля эксперимента я взял `Swift 1.5 Qwen3.8-27B Q8_0`, запустил его через LM Studio и подключил к Cline внутри Cursor.\n\nПолучилось медленно. Иногда очень медленно. Но при разборе проекта модель нашла баг: после `destroy()` отложенный `requestAnimationFrame` всё ещё мог изменить canvas. Я отдельно сверил эту цепочку с исходниками — она подтвердилась. На разбор небольшого TypeScript-проекта ушло около двух часов.\n\nПримерно эту границу мне и хотелось найти: что уже можно получить локально на обычной рабочей машине, а где до привычного опыта с frontier-моделями ещё далеко.\n\n## Почему Swift 1.5\n\nИзначально я сформулировал для себя довольно узкую задачу: TypeScript, в перспективе WebGL\u002FGLSL и работа с реальным проектом в агентном режиме. Скорость генерации была вторична, приоритет — качество кода.\n\nИз нескольких вариантов на основе Qwen3.8-27B остановился на Swift 1.5. В актуальной [карточке модели](https:\u002F\u002Fhuggingface.co\u002Fukisai\u002FSwift-1.5-Qwen3.8-27B-GGUF) авторы делают акцент на программировании и работе в агентном режиме, показывают результаты на LiveCodeBench и Terminal-Bench.\n\nВзял вариант Q8_0.\n\nМожно было взять вариант поменьше, тем более что видеопамяти всего 16 GB. Но задача была не выжать максимум токенов в секунду, а посмотреть, что даст Q8_0, если позволить части модели жить в RAM.\n\nQ8_0 сейчас занимает около 29 GB. В карточке модели он обозначен как вариант с максимальной fidelity. Для длинных задач в агентном режиме авторы рекомендуют Q6_K или выше.\n\nТо есть в видеокарту модель целиком не помещается даже близко.\n\n## 29 GB модели и 16 GB VRAM\n\nLM Studio на llama.cpp позволяет перенести часть модели на GPU, а остальное оставить в системной памяти.\n\nСначала получил рабочую конфигурацию с контекстом 32K:\n\n```text\nContext Length: 32768\nGPU Offload:    22\n```\n\nLM Studio оценил использование GPU примерно в 12.61 GB. `nvidia-smi` показал:\n\n```text\n13154 MiB \u002F 16376 MiB\n```\n\nПосле этого увеличил контекст до 65536. С прежней настройкой GPU Offload оценка расхода видеопамяти уже подбиралась к 14.5 GB, поэтому количество слоёв на GPU уменьшил до 19.\n\nФинальный вариант:\n\n```text\nContext Length: 65536\nGPU Offload:    19\n```\n\nОценка LM Studio:\n\n```text\nGPU:   12.54 GB\nTotal: 42.87 GB\n```\n\nФактически через `nvidia-smi`:\n\n```text\n12882 MiB \u002F 16376 MiB\n```\n\nОсталось около 3.4 GiB VRAM.\n\nПосле этой настройки локальный запуск перестал выглядеть для меня как задача \"влезет ли файл модели в видеокарту\". Не влезет. И не обязан.\n\nВ этой конфигурации видеокарта берёт часть работы на себя, остальные веса остаются в 64 GB RAM. Расход памяти зависит ещё и от размера контекста и KV-cache.\n\nПоэтому `GPU Offload = 19` — не универсальная рекомендация, а просто рабочее значение на моей машине. На другом железе нужно заново подобрать, сколько слоёв перенести на GPU, и обязательно проверить расход видеопамяти через `nvidia-smi`.\n\nСейчас LM Studio позволяет отдельно задавать [размер контекста и число слоёв на GPU](https:\u002F\u002Flmstudio.ai\u002Fdocs\u002Fcli\u002Flocal-models\u002Fload), а также учитывать эти параметры при оценке памяти.\n\n## Первый простой тест модель провалила\n\nПосле настройки хотелось проверить что-нибудь совсем небольшое.\n\nДал Swift задачу написать TypeScript-функцию для компиляции шейдера WebGL:\n\n```text\nНапиши на TypeScript функцию\n\ncompileShader(\n  gl: WebGL2RenderingContext,\n  type: GLenum,\n  source: string\n): WebGLShader\n\nФункция должна создать и скомпилировать shader. Если компиляция\nзавершилась ошибкой, получить текст через getShaderInfoLog(),\nудалить shader и бросить Error.\n\nВерни только код.\n```\n\nВ ответе была строка:\n\n```ts\nthrow new Error(Failed to create shader);\n```\n\nТо есть 27B-модель выдала код с синтаксической ошибкой на задаче, которую сложно назвать серьёзным испытанием.\n\nВ этот момент в LM Studio был выставлен `Temperature = 0.1`.\n\nНе падаем духом и не списываем модель в утиль. Настроил отдельный пресет:\n\n```text\nTemperature:       1.0\nTop K:             20\nTop P:             0.95\nMin P:             0\nRepeat Penalty:    1.0\nPresence Penalty:  0\nThinking:          ON\nReasoning Budget:  Unrestricted\n```\n\nПосле этого тот же тест прошёл нормально.\n\nОказалось, что эти значения практически совпадают с рекомендованными настройками генерации для Swift 1.5:\n\n```text\ntemperature:        1.0\ntop_p:              0.95\ntop_k:              20\nmin_p:              0\npresence_penalty:   0\nrepetition_penalty: 1\n```\n\nС локальной моделью мало скачать правильные веса. Результат зависит и от настроек запуска.\n\n## Около шести токенов в секунду\n\nПосле настройки скорость была примерно такой:\n\n```text\n~6 tok\u002Fs\n```\n\nВ одном из замеров:\n\n```text\n5.92 tok\u002Fs\n654 tokens\nthinking ~1:35\nMTP draft acceptance ~63.6%\n```\n\nВ другом MTP acceptance был около 65%.\n\nБыстрой такую работу не назовёшь. После облачных frontier-моделей разница хорошо чувствуется.\n\nНо для этого эксперимента скорость изначально была вторична. Пока модель думает несколько минут и возвращает полезный результат, с этим ещё можно жить.\n\nКак позже выяснилось, несколько минут — не худший вариант.\n\n## От локальной модели к агенту\n\nДля первого запуска использовал LM Studio ещё и потому, что через него легко получить локальный API.\n\nСервер поднялся на:\n\n```text\nhttp:\u002F\u002F127.0.0.1:1234\n```\n\nПроверка:\n\n```powershell\nInvoke-RestMethod http:\u002F\u002F127.0.0.1:1234\u002Fv1\u002Fmodels\n```\n\nпоказала модель:\n\n```text\nswift-1.5-qwen3.8-27b\n```\n\nПосле этого подключил Cline в Cursor:\n\n```text\nAPI Provider:   LM Studio\nModel:          swift-1.5-qwen3.8-27b\nContext Window: 65536\n```\n\nПолучилась такая цепочка:\n\n```text\nCursor\n  -> Cline\n    -> LM Studio\n      -> Swift 1.5 Qwen3.8-27B Q8_0\n```\n\nCline сейчас [напрямую поддерживает LM Studio](https:\u002F\u002Fdocs.cline.bot\u002Frunning-models-locally\u002Foverview). Стандартный адрес сервера — `localhost:1234`.\n\nСам API никаких сложностей не доставил, кроме одной мелочи с PowerShell: первый POST испортил кириллицу, и модель получила `????`.\n\nПришлось явно кодировать тело запроса в UTF-8:\n\n```powershell\n$body = @{\n    model = \"swift-1.5-qwen3.8-27b\"\n    messages = @(\n        @{\n            role = \"user\"\n            content = \"Ответь одной строкой: API работает\"\n        }\n    )\n    temperature = 1.0\n    top_p = 0.95\n} | ConvertTo-Json -Depth 10\n\n$utf8Body = [System.Text.Encoding]::UTF8.GetBytes($body)\n\n$response = Invoke-RestMethod `\n    -Uri \"http:\u002F\u002F127.0.0.1:1234\u002Fv1\u002Fchat\u002Fcompletions\" `\n    -Method Post `\n    -ContentType \"application\u002Fjson; charset=utf-8\" `\n    -Body $utf8Body\n\n$response.choices[0].message.content\n```\n\nПосле этого получил:\n\n```text\nAPI работает\n```\n\n## Сначала только читать\n\nПодключение агента прошло легко. Сложнее оказалось определить, сколько ему сразу разрешить.\n\nИзначально Auto-approve включал:\n\n```text\nRead\nEdit\nWeb Fetch\nMCP\n```\n\nДля начала оставил ему только:\n\n```text\nRead\n```\n\nEdit, Terminal и остальные действия — вручную. Cline работает в Plan mode.\n\nСначала хотелось понять, насколько предсказуемо локальная модель работает с настоящим репозиторием, и только потом разрешать ей что-либо менять.\n\nПод рукой не оказалось WebGL-проекта, и я решил поэкспериментировать над чем-то попроще. С прошлого Нового года у меня остался пакет `snowfall-canvas`, реализующий Canvas 2D \"новогодний снегопад\" для веб-приложений.\n\nSwift нормально это определил и не начал придумывать шейдеры или GLSL. В обзорном проходе он довольно точно восстановил структуру: типизированные массивы, таблицу значений для `fastSin`, пакетную отрисовку, работу с DPR, `ResizeObserver`, `visibilitychange`, адаптивную настройку производительности и ограничение количества частиц.\n\nБыл и менее приятный момент. Для обхода репозитория агент сначала предложил:\n\n```powershell\nGet-ChildItem d:\\tests\\snowfall-canvas -Recurse -File |\nWhere-Object {\n    $_.FullName -notmatch 'node_modules|\\.git|dist|build|out|coverage|\\.next'\n}\n```\n\nПроблема в том, что фильтрация здесь происходит уже после `-Recurse`. Тяжёлые каталоги всё равно могут физически обходиться.\n\nКоманду отклонил.\n\nПосле Reject Swift перестроил работу и перешёл к более точечному чтению и поиску.\n\n## AGENTS.md вместо надежды на хороший промпт\n\nПосле первого прохода добавил в проект `AGENTS.md`.\n\nГлавные правила были достаточно простыми:\n\n```text\n- без широкого recursive traversal корня;\n- не обходить node_modules, .git и другие тяжёлые каталоги;\n- предпочитать targeted reads\u002Fsearch;\n- repo-wide traversal только после разрешения;\n- для shell использовать PowerShell 7;\n- искать root cause, а не латать симптом;\n- не добавлять абстракции и зависимости без необходимости.\n```\n\nТуда же попал принцип \"Lazy senior\": сначала понять задачу, проверить YAGNI, поискать существующее решение, стандартную возможность платформы или уже установленную зависимость, и только потом писать новый код.\n\nCline распознал файл как правила проекта. Отдельно попросил его без обращения к инструментам перечислить действующие правила — основные ограничения он восстановил корректно. Поддержка [`AGENTS.md`](https:\u002F\u002Fdocs.cline.bot\u002Fcustomization\u002Fcline-rules) сейчас есть и в официальной документации Cline.\n\nДополнительно я использовал `.clineignore` для тяжёлых каталогов. В текущей документации Cline он уже [помечен как `deprecate soon`](https:\u002F\u002Fdocs.cline.bot\u002Fcustomization\u002Fclineignore). Ограничения на инструменты и ручное подтверждение действий для этого важнее.\n\nПосле обзорного анализа захотелось дать задачу, результат которой будет гораздо более жёсткой проверкой.\n\n## Найди настоящий баг или скажи, что его нет\n\nSwift получил задачу проверить код того же `snowfall-canvas` без правок.\n\nУсловия специально сформулировал так, чтобы модель не могла отделаться стандартным набором \"улучшений\":\n\n```text\n- ничего не редактировать;\n- не запускать команды;\n- не предлагать косметический рефакторинг;\n- не предлагать новые абстракции;\n- искать реальный defect \u002F edge case \u002F баг;\n- соблюдать AGENTS.md;\n- если убедительного дефекта нет — так и сказать.\n```\n\nЕсли проблема найдётся, требовалось указать:\n\n1. где она находится;\n2. сценарий воспроизведения;\n3. причину ошибки;\n4. минимальный фикс;\n5. одну проверку, которую стоит добавить.\n\nПосле этого Swift думал примерно **два часа**.\n\nДля небольшого TypeScript-проекта это чрезмерно. При настройке `Unrestricted` казался естественным выбором ради максимального качества, но на практике ждать пришлось слишком долго.\n\nЗато найденная проблема оказалась не косметикой.\n\n## requestAnimationFrame, который пережил destroy()\n\nВ обработке изменения размера был отдельный `requestAnimationFrame`.\n\nУпрощённо:\n\n```ts\nrequestAnimationFrame(() => {\n    this.resizeQueued = false;\n    this.resizeToContainer();\n});\n```\n\nПроблема в том, что id этого rAF нигде не сохранялся.\n\n`destroy()` при этом нормально завершал остальную работу экземпляра:\n\n- отменял rAF основного цикла отрисовки;\n- отключал `ResizeObserver`;\n- снимал обработчики событий.\n\nНо про отложенный rAF для изменения размера он ничего не знал.\n\nSwift восстановил такой сценарий:\n\n```ts\nconst snow = new SnowfallCanvas({ canvas, container });\nsnow.init();\n```\n\nЗатем:\n\n1. контейнер меняет размер;\n2. `ResizeObserver` или `requestResize()` запускает обработку изменения размера;\n3. `queueResize()` ставит `requestAnimationFrame`;\n4. до следующего кадра вызывается `snow.destroy()`;\n5. экземпляр уже завершил работу;\n6. отложенный вызов всё равно срабатывает;\n7. вызывается `resizeToContainer()`;\n8. затем `applyResize()`.\n\nВ итоге старый экземпляр после `destroy()` снова меняет:\n\n```text\ncanvas.width\ncanvas.height\nctx.setTransform(...)\n```\n\nЭто уже не просто внутренний флаг, который остался в неправильном состоянии.\n\nCanvas принадлежит коду, использующему библиотеку. После `destroy()` разумно ожидать, что старый экземпляр его больше не тронет.\n\nОсобенно неприятно, если после уничтожения компонента canvas сразу использует другой код отрисовки. Через кадр старый `SnowfallCanvas` приходит со своим отложенным вызовом и меняет буфер холста.\n\n## Минимальный фикс\n\nSwift предложил отдельно сохранять id отложенного вызова:\n\n```ts\nprivate resizeRafId: number | null = null;\n```\n\nПри планировании:\n\n```ts\nprivate queueResize = (): void => {\n    if (this.resizeQueued) return;\n\n    this.resizeQueued = true;\n\n    this.resizeRafId = requestAnimationFrame(() => {\n        this.resizeRafId = null;\n        this.resizeQueued = false;\n        this.resizeToContainer();\n    });\n};\n```\n\nА в `destroy()`:\n\n```ts\nif (this.resizeRafId !== null) {\n    cancelAnimationFrame(this.resizeRafId);\n    this.resizeRafId = null;\n    this.resizeQueued = false;\n}\n```\n\nТо есть без отдельного планировщика и новых абстракций. Сохраняем id отложенного `requestAnimationFrame` и отменяем вызов в `destroy()`.\n\nДля теста на этот баг модель предложила тот же сценарий в минимальном виде:\n\n```text\nrequestResize()\n-> rAF scheduled\n-> destroy()\n-> cancelAnimationFrame(same id)\n```\n\nЭтого достаточно, чтобы проверить причину ошибки без симуляции Vue или сложных сценариев взаимодействия с DOM.\n\nПосле ответа я отдельно сверил найденную цепочку с исходниками.\n\nОна подтвердилась:\n\n```text\nqueueResize()\n-> requestAnimationFrame()\n-> id не сохраняется\n\ndestroy()\n-> отменяет только основной rafId\n-> resize-rAF остаётся pending\n\npending callback\n-> resizeToContainer()\n-> applyResize()\n-> canvas.width \u002F canvas.height \u002F setTransform()\n```\n\nПосле проверки эксперимент для меня стал заметно интереснее простого запуска локальной LLM.\n\nОбщий обзор проекта может звучать убедительно и без полезных находок. Здесь модель нашла конкретный баг, который подтвердился при проверке исходников, и предложила небольшой фикс, устраняющий его причину.\n\nЗаодно она заметила ещё один подозрительный случай в адаптивной настройке производительности: механизм снижения нагрузки потенциально способен увеличить количество частиц. Но не стала смешивать его с основной находкой и оставила как дополнительное замечание, требующее отдельной проверки.\n\n## Хорошее ревью ещё не означает автономного агента\n\nДва часа на разбор — первое очевидное ограничение.\n\nДля такого проекта это слишком дорого по времени, даже если электричество и API-токены в данном случае не считаются.\n\nПричём у Swift 1.5 есть несколько уровней reasoning effort. Авторы модели отдельно [тестируют `xhigh`, `medium` и `low`](https:\u002F\u002Fhuggingface.co\u002Fukisai\u002FSwift-1.5-Qwen3.8-27B-GGUF), так что максимальный режим вовсе не обязательно использовать для каждого ревью.\n\nЭто один из следующих экспериментов: снизить reasoning effort и посмотреть, насколько быстрее модель работает и что происходит с качеством поиска багов.\n\nЕсть и менее заметная проблема — локальной модели всё равно нельзя автоматически верить в отчёте о собственных действиях.\n\nУсловие задачи было:\n\n```text\nне запускай команды\n```\n\nSwift позже написал:\n\n```text\nникаких команд изменения состояния не запускал\n```\n\nФормулировки похожи, но смысл уже чуть-чуть другой.\n\nЕсли по журналу вызовов видно, что агент действительно не обращался к терминалу, никакого инцидента нет. Но такая подмена показывает, как модель может истолковать ограничение по-своему и затем уверенно описать собственное поведение.\n\nТо же относится к заявлениям вроде \"посимвольно сравнил\" или к проверкам `dist\u002F`: если это существенно для вывода, лучше смотреть журнал вызовов инструментов, чем принимать отчёт модели за доказательство.\n\nПоэтому режим только для чтения и ручное подтверждение Edit\u002FTerminal в начале оказались вполне оправданными.\n\nНе потому, что Swift постоянно пытался сделать что-то опасное. Большая часть поведения как раз выглядела разумно. Просто цена ошибки гораздо ниже, когда сначала понимаешь, как конкретная модель пользуется инструментами и соблюдает ограничения.\n\n## Конфигурация для повторения\n\n### Железо\n\n```text\nNVIDIA GeForce RTX 4070 Ti SUPER, 16 GB VRAM\n64 GB DDR5-6400\nWindows\n```\n\n### Модель\n\n```text\nukisai\u002FSwift-1.5-Qwen3.8-27B-GGUF\nQ8_0\n```\n\n### LM Studio\n\n```text\nContext Length:             65536\nGPU Offload:                19\nFlash Attention:            ON\nOffload KV Cache to GPU:    ON\nK\u002FV Cache Quantization:     OFF\nMax Concurrent Predictions: 1\nMTP:                        ON\nmmap:                       ON\nKeep Model in Memory:       ON\nRoPE:                       Auto\n```\n\nНа другой машине `GPU Offload` нужно подобрать заново. Смысл настройки — оставить нормальный запас по VRAM и проверить результат через `nvidia-smi`.\n\n### Настройки генерации\n\n```text\nTemperature:       1.0\nTop K:             20\nTop P:             0.95\nMin P:             0\nRepeat Penalty:    1.0\nPresence Penalty:  0\nThinking:          ON\nReasoning Budget:  Unrestricted\n```\n\n`Unrestricted` — параметр этого эксперимента, а не моя рекомендация. После двухчасового разбора как раз его хочется скорректировать первым.\n\n### Локальный API\n\n```text\nhttp:\u002F\u002F127.0.0.1:1234\n```\n\nID модели:\n\n```text\nswift-1.5-qwen3.8-27b\n```\n\n### Cline\n\n```text\nAPI Provider:   LM Studio\nModel:          swift-1.5-qwen3.8-27b\nContext Window: 65536\nMode:           Plan\nAuto-approve:   Read only\n```\n\nДля локальных моделей Cline сейчас [рекомендует](https:\u002F\u002Fdocs.cline.bot\u002Frunning-models-locally\u002Foverview) давать задачи с чёткими границами и начинать новую задачу, когда контекст становится слишком большим. Машины с 64 GB+ RAM документация рассматривает как вариант для более крупных моделей и большего контекста.\n\nВ моём случае связка:\n\n```text\nCursor\n  -> Cline\n    -> LM Studio\n      -> Swift 1.5 Qwen3.8-27B Q8_0\n```\n\nдошла от простой проверки API до разбора реального TypeScript-проекта без правок и нашла баг, который подтвердился по исходникам.\n\nНо называть её после этого полноценной заменой frontier-агента для работы с кодом, мягко говоря, я бы не стал.\n\nДальше планирую проверить, как Swift сама внесёт этот фикс, какой сделает diff, напишет ли нормальный тест на этот баг и сможет ли пройти цикл:\n\n```text\ninspect\n-> edit\n-> test\n-> fix\n```\n\nНе проверены: изменения в нескольких файлах, настоящий WebGL\u002FGLSL, большой репозиторий, длинная сессия с агентом.\n\nБаг найден и проверен. Можно включить Act mode, оставить Edit и Terminal на ручном подтверждении и смотреть, сможет ли та же локальная 27B-модель сама аккуратно довести исправление до работающего теста.","chto-mozhet-lokalnyj-coding-agent-na-16-gb-vram-1","2026-10-03","2026-10-09T15:10:18.581Z",[],{"id":24,"documentId":25,"title":26,"content":27,"slug":28,"author":10,"displayDate":29,"publishedAt":30,"tags":31},101,"pg9d6k0p2ggrjlpwnp0vmfhu","Публичный контракт вместо внутренних формул в typographics 5","Выпустил `typographics@5.0.0`. Изменение в самом CSS небольшое: блоки кода теперь по умолчанию используют ту же плавную базу, что и основной текст. После `3.0.0` постепенно упрощаю настройку и подключение пакета, чтобы пользователю не приходилось разбираться во внутренних формулах и путях к собранным файлам.\n\nВ `3.0.0` убрал `html { font-size: 10px }` и разделил основной текст и заголовки на две плавные шкалы. Модель стала аккуратнее. Теперь её можно настраивать на нескольких уровнях. Иногда нужно просто сделать типографику чуть меньше внутри статьи. Иногда — задать точные размеры одному заголовку на границах адаптивного диапазона. А подключение пакета не должно требовать знания пути к собранному CSS.\n\nЗа несколько релизов всё это сложилось в отдельный публичный контракт: общие коэффициенты, точные границы, импорт из корня пакета и явные способы переопределения.\n\n## Масштаб без копирования формулы\n\nВ `3.0.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Они масштабируют уже рассчитанную плавную базу:\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\nCSS-переменные [наследуются и участвуют в каскаде](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-variables-1\u002F#defining-variables), поэтому коэффициенты можно задавать и для всего документа, и для отдельного блока:\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В `.article` локальное значение `--t-body-scale: 0.95` заменяет унаследованное от корня `0.9`. Для заголовков продолжает действовать коэффициент `0.9`. Правило `.hero` явно задаёт тот же масштаб заголовков в этой области.\n\nЧтобы изменить масштаб группы текста, достаточно задать коэффициент. Копировать `clamp()` и внутренние `calc()` больше не нужно.\n\nПатч `3.0.1` перед этим ничего глобально в CSS не менял — я только исправил примеры в README.\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()`. Явно заданная граница используется напрямую, без умножения на `--t-heading-scale` и коэффициент роли. Можно задать одну границу, оставив вторую из общей шкалы.\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 из корня пакета:\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 основной способ подключения теперь указан через имя пакета. Этот вариант рассчитан на сборщик с поддержкой [CSS-импортов](https:\u002F\u002Fvite.dev\u002Fguide\u002Ffeatures.html#css):\n\n```js\nimport 'typographics';\n```\n\nПрямой путь к CSS внутри `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.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По умолчанию блок кода использует плавную базу основного текста и коэффициент `--t-body-scale`. Чтобы изменить масштаб только кода, достаточно `--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В `5.0.0` явный `--t-code-block-font-size` заменяет плавный расчёт целиком. Коэффициенты `--t-body-scale` и `--t-code-block-scale` к нему не применяются. При `--t-code-block-font-size: 13px` и `--t-body-scale: 0.9` прежняя формула даёт `11.7px`, новая — `13px`.\n\nТакой API для `typographics` оказался удобнее, чем настройка через внутренние формулы.\n\nК `5.0.0` публичный API выглядит достаточно цельно. `--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- [Vite: импорт CSS](https:\u002F\u002Fvite.dev\u002Fguide\u002Ffeatures.html#css)\n- [Исходники typographics 5.0.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ftypographics\u002Ftree\u002F2e5b4b78acbc9b373f720f82d956ec4ffedd65a8)","publichnyj-kontrakt-vmesto-vnutrennih-formul-v-typographics-5","2026-09-03","2026-10-11T15:40:15.549Z",[],{"id":33,"documentId":34,"title":35,"content":36,"slug":37,"author":10,"displayDate":38,"publishedAt":39,"tags":40},114,"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## Запись в кэш после вставки SVG\n\nТа же логика применяется к записи в кэш.\n\nВ `4.2` после `fetch` сначала выполнялся `storage.set()`. Если запись возвращала ошибку, `init()` завершался раньше вставки в DOM.\n\nТеперь последовательность другая:\n\n```text\nfetch\n  -> подготовка SVG к вставке\n  -> вставка в DOM\n  -> запись в кэш\n```\n\nСначала SVG нужно успешно вставить в DOM. Только после этого я пытаюсь сохранить исходную строку в хранилище. Ниже показана обработка `Result`, возвращённого из `storage.set()`:\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Во-первых, если `storage.set()` возвращает `Result` с ошибкой, уже успешная загрузка и вставка не превращаются в `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-документом, ошибка логируется, а загрузчик переходит к сетевому запросу. Если свежий SVG удалось вставить, загрузчик пытается обновить запись в кэше.\n\nТак повреждённая запись кэша становится состоянием, из которого можно восстановиться, а не тупиком.\n\nПри этом я не хочу маскировать любое исключение под обычное отсутствие записи в кэше. В показанном коде ошибка вставки остаётся критической, если её `cause` приводится к `true` при проверке условия. Например, пользовательский санитайзер может бросить исключение. В таком случае молча скачать тот же SVG ещё раз — не восстановление, а сокрытие реальной проблемы в пользовательском коде.\n\nТо есть запасной путь здесь довольно узкий: испорченный документ из кэша можно заменить свежим, критическую ошибку — нет.\n\n## Исключения не должны обходить Result\n\n`Result` появился в `3.0`. Тогда же я зафиксировал, что штатные ошибки `init()` возвращаются через `Result`, а не выбрасываются наружу. Но исключения браузерных API и пользовательского кода всё ещё могли нарушить этот контракт.\n\nК `5.0` нашёл несколько таких мест.\n\nСамый простой пример — некорректный CSS-селектор:\n\n```ts\ncreateIconly({\n  container: '[',\n});\n```\n\n`document.querySelector('[')` бросает `DOMException`. Если не перехватить это исключение, оно выходит за пределы `Result`-контракта.\n\nТеперь `resolveContainer()` перехватывает исключение при поиске элемента и возвращает ошибку с кодом `container_invalid` через `Result`:\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\n## Материалы\n\n- [Исходники iconly 5.0.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ficonly\u002Ftree\u002F975a555a7ac4cd5ec394257994aade52df9fc90a)","kesh-ne-dolzhen-lomat-zagruzku-sprajta","2026-08-11","2026-10-11T16:23:12.132Z",[],{"id":42,"documentId":43,"title":44,"content":45,"slug":46,"author":10,"displayDate":47,"publishedAt":48,"tags":49},106,"v415k9na7rfuvuwadzaux2fq","playbackRate не вывозит","В `mode=\"scroll\"` лента должна ускоряться вместе со страницей и сохранять инерцию, когда прокрутка уже остановилась. После перехода на Web Animations API (WAAPI) скорость можно менять через `playbackRate`. На бумаге это ровно то, что нужно — положительное значение двигает анимацию вперёд, отрицательное — назад, ноль останавливает движение.\n\nНа практике при смене направления лента дёргалась. Проверял в разных браузерах на Windows, iOS и Android. Рывок возникал во всех проверенных браузерах. Исправлять пришлось не формулу скорости, а способ её обновления у уже запущенной анимации.\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Импорт пакета автоматически регистрирует Custom Element:\n\n```ts\nimport 'marquee-content';\n```\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 и переводит её в добавку к масштабу времени. В этой формуле `deltaY` — смещение в пикселях, а `deltaTime` — интервал в секундах:\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Проблема проявлялась при обновлении уже запущенной анимации. Сглаживание меняло масштаб десятки раз за разворот, и при каждом таком изменении код заново присваивал `playbackRate`.\n\n[Спецификация Web Animations](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fweb-animations-1\u002F#dom-animation-playbackrate) описывает это прямо. Установка `playbackRate` — синхронное обновление без попытки согласовать состояние с анимацией, которая может идти в другом потоке или процессе. В результате уже запущенная анимация может скачком перейти к другой позиции. Для смены скорости без такого скачка предусмотрен асинхронный `updatePlaybackRate()`.\n\nСмена направления как раз меняет знак масштаба. Инерция ещё не дошла до нуля, целевое значение уже отрицательное, сглаживание переводит `currentTimeScale` через ноль. На этом участке присваивание повторялось каждый кадр и давало заметный рывок.\n\nВозврат к ручному `offset` снова заставил бы главный поток писать `transform`. Дополнительное сглаживание тоже не устраняло причину: оно лишь увеличивало число записей в `playbackRate`.\n\n## updatePlaybackRate() сохраняет позицию\n\nВ `5.0.3` заменил присваивание в покадровом обновлении вызовом `updatePlaybackRate()`:\n\n```ts\nif (this.scrollAnimation.playbackRate !== this.currentTimeScale) {\n  this.scrollAnimation.updatePlaybackRate(this.currentTimeScale);\n}\n```\n\nМетод [согласовывает смену скорости с текущей позицией анимации](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FAnimation\u002FupdatePlaybackRate). У работающей анимации `playbackRate` обновляется асинхронно. Завершения обновления можно дождаться через Promise `ready`. Для разворота это и нужно: лента не прыгает к другому кадру цикла, меняется только темп.\n\nСоздание анимации по-прежнему начинается с присваивания. Здесь `playbackRate` задаёт стартовую скорость вместе с начальным `currentTime`.\n\nПри повторной ручной проверке того же сценария — прокрутки вверх и вниз на телефоне и на ПК — рывка уже не было. До замены присваивания он возникал во всех проверенных браузерах.\n\n## Что осталось на JavaScript\n\nВ `mode=\"auto\"` JavaScript почти не участвует в самом движении: ему остаются пауза, `prefers-reduced-motion` и пересборка дорожки. Режиму `scroll` всё ещё нужны обработчик прокрутки, оценка скорости и короткий цикл `requestAnimationFrame`.\n\nВ этой реализации нельзя убрать `requestAnimationFrame` после перехода на WAAPI без потери выбранного сглаживания. Без промежуточных кадров масштаб времени сразу принимал бы целевое значение, даже при обновлении через `updatePlaybackRate()`. Компромисс такой: браузер двигает ленту, скрипт обновляет её скорость, пока продолжается затухание ускорения или сглаживание масштаба.\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- [Исходники marquee-content 5.0.3](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fmarquee-content\u002Ftree\u002F6a530d1cffd888564e342665862e27b410a50bf4)","playback-rate-ne-vyvozit","2026-08-08","2026-10-11T15:48:13.415Z",[],{"id":51,"documentId":52,"title":53,"content":54,"slug":55,"author":10,"displayDate":56,"publishedAt":57,"tags":58},113,"ashjhdkt56ram23r40w9vx7e","Два пути к SVG-спрайту в Iconly","В `3.0` у iconly появился довольно понятный контракт загрузки: при вызове `init()` загрузчик проверяет кэш, при необходимости получает внешний 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Загрузчик работает с кэшем, получает внешний файл при необходимости, разбирает SVG и вставляет его в DOM.\n\nДля импортированных иконок мне нужен небольшой сборщик, которому можно передать только нужные объекты:\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Если нужна только SVG-строка, есть `buildSpriteString()`:\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`, сети или хранилищу. Поэтому функцию можно использовать там, где нужен SVG в виде строки, например в 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`. Теперь его используют оба сценария. Метод `render()` созданного спрайта использует тот же `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` я добавил отдельный разбор SVG через `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\n## Материалы\n\n- [Исходники iconly 4.2.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ficonly\u002Ftree\u002Ff917ef86af544666e08287e0ddea5dbd90918d36)","dva-puti-k-svg-sprajtu-v-iconly","2026-07-11","2026-10-11T16:22:44.574Z",[],{"id":60,"documentId":61,"title":62,"content":63,"slug":64,"author":10,"displayDate":65,"publishedAt":66,"tags":67},124,"jesxp2we692xubc1lvx26tvm","Полируем clicktone","После изменений в Web Audio поведение clicktone меня устраивает гораздо больше, чем инфраструктура вокруг пакета.\n\nЗдесь есть неприятный класс ошибок: исходники проходят юнит-тесты и проверку типов, сборка завершается успешно, а опубликованный пакет всё равно не работает при одном из способов импорта. Пользователь ведь устанавливает не `src`. Он получает конкретный архив с JavaScript, декларациями типов и `package.json`.\n\nВ опубликованном `2.1.0` такая проблема была вполне конкретной: `index.d.ts` ссылался на отсутствующий `main.d.ts`, а CommonJS-файл назывался `index.cjs.js` внутри пакета с `type: module`. Проверки исходников не подтверждали корректность этих файлов и путей.\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` недостаточно: в обеих ветках `exports` должны быть правильно указаны пути к JavaScript и декларациям типов. Эти пути тоже входят в контракт пакета.\n\nПубличный `.\u002Fdist\u002F*` я убрал. Внутренние файлы не стоит случайно превращать во внешний API только потому, что они лежат в опубликованной папке.\n\nСостав пакета тоже ограничил явно:\n\n```json\n{\n  \"files\": [\"dist\", \"README.md\", \"LICENSE\"]\n}\n```\n\nИсходники, тесты и локальные конфиги остаются в репозитории. В состав npm-пакета я их не включаю.\n\n## Smoke test для dist\n\nЮнит-тесты clicktone проверяют поведение исходного кода. После сборки нужно проверить, появились ли ожидаемые файлы и можно ли загрузить модули ESM и CJS.\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Релиз перестал быть просто командой после удачной сборки.\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- [Исходники clicktone 3.0.2](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fclicktone\u002Ftree\u002F3cd74e5fe810883641456d8a09a239ad2c291efc)","poliruem-clicktone","2026-07-03","2026-10-11T16:42:16.732Z",[],{"id":69,"documentId":70,"title":71,"content":72,"slug":73,"author":10,"displayDate":74,"publishedAt":75,"tags":76},117,"zz71n3thhwzglwwguq7j23sf","Vue поверх DOM-контроллера, а не вместо него","После `2.0.0` DOM API DialogLite позволяет передать готовый элемент или селектор, настроить закрытие, фокус и прокрутку, а затем явно уничтожить контроллер через `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\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 не включается: адаптер использует 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';\nimport 'dialog-lite\u002Fdialog-lite.css';\n\nconst dialogRef = ref\u003CHTMLElement | null>(null);\n\nconst { isOpen, open, close } = useDialogLite(dialogRef, {\n  closingBackdrop: true,\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` с элементом или функцию, которая этот элемент возвращает. Автоматическая инициализация происходит после монтирования, когда DOM уже доступен.\n\nCSS в этом примере подключён отдельно: Vue-адаптер сам его не внедряет. Настройка `mainContent` оставлена по умолчанию. В `2.1.0` значение `null` не отключает поиск `#main-content`, потому что контроллер заменяет его значением по умолчанию.\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## Компонент для стандартной обёртки\n\nComposable оставляет разметку приложению. Но если стандартная структура DialogLite устраивает, каждый раз писать корневой элемент, фон и контейнер тоже не хочется.\n\nДля этого появился `DialogLiteRoot`:\n\n```vue\n\u003Cscript setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { DialogLiteRoot } from 'dialog-lite\u002Fvue';\nimport 'dialog-lite\u002Fdialog-lite.css';\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  >\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 нужно учитывать SSR: наличие Vue API ещё не означает, что DOM можно трогать на сервере.\n\n`useDialogLite()` не создаёт контроллер при импорте модуля. Автоматическая инициализация происходит в `onMounted()`, то есть уже на клиенте. Поэтому `dialog-lite\u002Fvue` можно импортировать в SSR-приложении без немедленного обращения к DOM. Ручные `init()` и `open()` нужно вызывать там, где DOM уже доступен.\n\nЭто не модуль Nuxt и не отдельная SSR-абстракция. Для страницы с SSR граница остаётся простой: DialogLite инициализируется на клиенте, а сам диалог при необходимости можно поместить в `\u003CClientOnly>`.\n\nПрятать браузерную природу библиотеки за дополнительной фреймворковой магией здесь не хочется.\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.\n\nПри этом можно выбрать уровень обвязки. `useDialogLite()` подходит, когда разметка принадлежит приложению. `DialogLiteRoot` — когда стандартная структура устраивает и хочется сократить шаблонный код.\n\n## Материалы\n\n- [Исходники dialog-lite 2.1.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fdialog-lite\u002Ftree\u002F5251f50a8ffdff464ee6caa7b2977476979f3da1)","vue-poverh-dom-kontrollera-a-ne-vmesto-nego","2026-06-08","2026-10-11T16:35:57.241Z",[],{"id":78,"documentId":79,"title":80,"content":81,"slug":82,"author":10,"displayDate":83,"publishedAt":84,"tags":85},123,"gnxwj8ybwrbrexf3hhmg3cwj","Один AudioContext для всех звуков","В игре HopperGame звук перестал быть разовым эффектом, который можно включить и забыть.\n\nОдин звук должен идти циклически, пока персонаж находится в определённом состоянии. Другой при повторном `play()` можно наложить поверх предыдущего. Третий нужно прервать или вообще не запускать повторно, пока он ещё звучит.\n\nПосле возврата в Safari на iOS одного `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`, `muted`, `throttle`, `pitchVariation` и собственный `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Поведение проверял в разных браузерах на Windows, iOS и Android. После `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` я не стал. Полное восстановление откладывается до следующего вызова `unlock()`:\n\n```ts\nif (this.#needsHardRecovery) this.#recreate();\n```\n\n`unlock()` вызывают обработчики пользовательских жестов и сам `play()`. Если запуск связан с жестом, браузер с большей вероятностью разрешит снова поднять аудио. Наличие жеста библиотека не проверяет, поэтому программный вызов тоже может запустить восстановление.\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Описанные примеры относятся к браузерному ESM API. У опубликованного `2.1.0` остаются проблемы с подключением CommonJS и типов: файл `index.cjs.js` содержит CommonJS-код внутри пакета с `type: module`, а `index.d.ts` ссылается на отсутствующий `main.d.ts`.\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- [Исходники clicktone 2.1.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fclicktone\u002Ftree\u002F90f9d4463db9f898fd307c8050c889801e16402a)","odin-audio-context-dlya-vseh-zvukov","2026-06-03","2026-10-11T16:42:01.367Z",[],{"id":87,"documentId":88,"title":89,"content":90,"slug":91,"author":10,"displayDate":92,"publishedAt":93,"tags":94},112,"kcybzkzc77id357qven9oau2","Делаем API Iconly предсказуемым","В `2.0` в основном приводил в порядок внутреннее устройство iconly, сохраняя прежний способ использования. В `3.0` занялся публичным API. Я хотел, чтобы по результату инициализации было понятно, что произошло, а отдельные части библиотеки можно было подменять и тестировать.\n\nЗаодно это часть общей работы по унификации моих библиотек. Мне нужен единый подход к API: явные результаты операций, небольшой публичный интерфейс и заменяемые зависимости там, где это действительно полезно.\n\nВ `3.0.0` `new Iconly()` уступил место `createIconly()`, `init()` начал возвращать `Result`, а хранилище получило собственный интерфейс. Основную реализацию я разделил на несколько модулей: ядро, работа с DOM, загрузка, ошибки и адаптеры хранилища.\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});\n\nawait iconly.init();\n```\n\nНо из результата вызова было невозможно понять, загрузился ли спрайт. `init()` возвращал `Promise\u003Cvoid>`, а ошибки выполнения обрабатывались внутри библиотеки. Для небольшой утилиты этого достаточно. Я хотел явно различать ошибки контейнера, загрузки, хранилища и разбора SVG, чтобы пользователю не приходилось угадывать результат по состоянию DOM.\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Так ядро может последовательно выполнять операции и проверять их результаты в одном формате.\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Второй метод, `abort()`, отменяет уже начавшийся `fetch`. Внутри `createIconly()` хранится `AbortController`, а отменённый запрос превращается в обычный результат с кодом `fetch_aborted`. Пока `init()` читает кэш, запрос может ещё не начаться, поэтому немедленный вызов `abort()` после `init()` не гарантирует отмену.\n\nНапример, отмену активного запроса можно привязать к кнопке:\n\n```html\n\u003Cbutton id=\"cancel-icons\" type=\"button\">Отменить загрузку\u003C\u002Fbutton>\n```\n\n```ts\nimport { createIconly } from 'iconly';\n\nconst iconLoader = createIconly({ file: '.\u002Fsprite.svg' });\nconst cancelButton = document.querySelector\u003CHTMLButtonElement>('#cancel-icons')!;\n\ncancelButton.addEventListener('click', () => iconLoader.abort());\n\nconst result = await iconLoader.init();\n```\n\n## Хранилище можно заменить\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## Чтение кэша перед загрузкой SVG\n\nДо `3.0` IndexedDB уже использовался, но сама последовательность была неидеальной: iconly сначала выполнял `fetch()`, а потом читал сохранённую запись. Получалось, что кэш не мог убрать сетевой запрос.\n\nПосле разделения хранилища я изменил порядок операций. Сокращённая схема, без проверок `Result` и обработки ошибок:\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Сначала читается хранилище. Если сохранённых SVG-данных нет, выполняется `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## Проверка основных сценариев\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Контракт `Result` позволяет проверять исход операции напрямую, а сменное хранилище — тестировать основной сценарий без IndexedDB.\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Предсказуемый 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Поменялся и селектор обёртки спрайта. В выбранном контейнере создаётся элемент с `data-iconly` вместо прежнего `#iconset`:\n\n```html\n\u003Cdiv data-iconly=\"iconset\" aria-hidden=\"true\">...\u003C\u002Fdiv>\n```\n\nВнутренний селектор больше не опирается на глобальный `id`. Атрибут `aria-hidden=\"true\"` исключает служебную обёртку из дерева доступности.\n\n## Материалы\n\n- [Исходники iconly 3.0.2](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ficonly\u002Ftree\u002F833d5037aa34fbe288e09ecad6be2116ad3adee5)","delaem-api-iconly-predskazuemym","2026-01-25","2026-10-11T16:21:34.760Z",[],{"id":96,"documentId":97,"title":98,"content":99,"slug":100,"author":10,"displayDate":101,"publishedAt":102,"tags":103},118,"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 был зашит внутри контроллера:\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` блокировка прокрутки включена по умолчанию и управляется настройкой `lockScroll`:\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\nПосле этой переработки DialogLite всё ещё остаётся DOM-контроллером. Он не рендерит содержимое, не вводит дерево компонентов и не управляет бизнес-логикой окна.\n\nНо дефолты теперь действительно работают как дефолты, а не как скрытые требования. Старую схему можно оставить почти без изменений или передать свои элементы, селекторы, тайминги и часть поведения модального окна через настройки.\n\n## Материалы\n\n- [Исходники dialog-lite 2.0.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fdialog-lite\u002Ftree\u002Fbad4e8345c2cbf6a5f40f588d6afd875282d9c65)","zhyostkij-dom-kontrakt-nachal-meshat","2025-12-16","2026-10-11T16:37:17.757Z",[],{"id":105,"documentId":106,"title":107,"content":108,"slug":109,"author":10,"displayDate":110,"publishedAt":111,"tags":112},102,"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.0` решил отказаться от привязки расчётов к фиксированным `10px`. Библиотека должна учитывать корневой размер шрифта, который задаёт приложение.\n\n## Без привязки к корню 10px\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.0` вместо фиксированных `10px` в слое `reset` используется `font-size: 100%`:\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Ниже — параметры двух шкал, без полной формулы `clamp()`:\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Причина разделения практическая. Заголовкам обычно нужен больший диапазон изменения размера, чем абзацам. При общей базе изменение её границ влияет и на заголовки, и на основной текст.\n\nПри корневом размере шрифта `16px` и линейном изменении между `600px` и `1440px` базы выглядят так:\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Параллельно с новой шкалой пересобрал CSS-правила typographics. Пакет всё чаще используется как базовые стили документа, поэтому странно требовать класс для каждого обычного `h2` или `p`. В `3.0.0` обычные HTML-элементы тоже получают стили текстовых ролей:\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()`. У него [нулевая специфичность](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fselectors-4\u002F#zero-matches), поэтому базовое правило для HTML-элемента легко переопределить в CSS проекта.\n\nПо той же причине весь CSS теперь разложен по слоям каскада:\n\n```scss\n@layer reset, tokens, typography, components, utilities;\n```\n\nЗаполнены слои `tokens`, `reset` и `typography`. Обычные декларации CSS проекта вне слоёв имеют [приоритет над обычными декларациями внутри слоёв](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-cascade-5\u002F#layer-order). Получается нужное для библиотечного 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`, но сама система вокруг них теперь другая. Проект может переопределить корневой размер шрифта. Основной текст и заголовки масштабируются независимо. Семантические элементы получают базовые значения, которые легко переопределить в CSS проекта.\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- [Исходники typographics 3.0.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ftypographics\u002Ftree\u002F33b3ef75caee6bdb761fce79f05ec74578a2e622)","dve-plavnye-shkaly-vmesto-kornya-10px","2025-11-04","2026-10-11T15:40:51.911Z",[],{"id":114,"documentId":115,"title":116,"content":117,"slug":118,"author":10,"displayDate":119,"publishedAt":120,"tags":121},104,"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` я начал с маленького правила для абзацев. Последнему абзацу в группе обычно не нужен нижний отступ, поэтому в миксине абзацев появилось:\n\n```scss\n&:last-of-type {\n  margin-block-end: 0;\n}\n```\n\nВ обычной горизонтальной разметке (`writing-mode: horizontal-tb`) `margin-block-end` соответствует нижнему отступу.\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` хотелось, чтобы интервалы зависели от `line-height` каждого элемента.\n\n## Отступ как доля строки\n\nВ `2.4.0` я перевёл основные вертикальные интервалы на единицу `lh`. В отступах она [соответствует значению line-height элемента, выраженному в длине](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fcss-values-4\u002F#font-relative-lengths). Поэтому `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При `font-size: 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Задача не в \"идеальной\" базовой сетке на весь документ. Хотел связать вертикальные отступы с заданным `line-height` элементов и за счёт этого получить более цельный ритм при разных размерах текста.\n\n## last-of-type оказался слишком буквальным\n\nПосле унификации миксинов быстро нашлась проблема в правиле для последнего элемента. В `2.4.2` я применил его и к заголовкам, и к абзацам:\n\n```scss\n&:last-of-type {\n  margin-block-end: 0;\n}\n```\n\nПсевдокласс [last-of-type](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FCSS\u002FReference\u002FSelectors\u002F:last-of-type) выбирает последний элемент с определённым HTML-тегом среди элементов с общим родителем. Единственный элемент с таким тегом тоже подходит. Если в небольшом блоке находится только один абзац, селектор срабатывает и убирает отступ, хотя после блока он всё ещё может быть нужен.\n\nВ `2.4.3` вынес сброс отступа в отдельный миксин и исключил этот случай:\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Получилось чуть длиннее, зато правило теперь описывает нужный случай точнее: убрать хвост у последнего элемента с данным HTML-тегом, только если у того же родителя есть и другие элементы с таким тегом.\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: тип length и единица lh](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FCSS\u002FReference\u002FValues\u002Flength)\n- [Исходники typographics 2.4.7](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ftypographics\u002Ftree\u002Fb609696469b5e5f09d590d01eb4437db450d4e67)","vertikalnyj-ritm-na-lh-v-typographics","2025-09-20","2026-10-11T15:41:42.248Z",[],{"id":123,"documentId":124,"title":125,"content":126,"slug":127,"author":10,"displayDate":128,"publishedAt":129,"tags":130},122,"rn9rvxezqx4f3ivdrc6voh0s","Один play() для звука в мини-играх","В небольших промо-играх звук обычно нужен как отклик на действие пользователя, например нажатие кнопки.\n\nЗа таким эффектом быстро появляется однообразная обвязка: загрузить файл, декодировать его через Web Audio, создать источник звука, подключить его к выходу и не забыть про приостановленный `AudioContext` в мобильных браузерах.\n\nПовторять это из проекта в проект не хотелось, поэтому для clicktone я сформулировал узкую задачу: приложение решает, когда должен прозвучать эффект, а библиотека даёт для этого `play()`.\n\n```js\nimport ClickTone from 'clicktone';\n\nconst clickSound = new ClickTone({ file: '.\u002Fsound.mp3' });\n\nmyButton.addEventListener('click', () => clickSound.play());\n```\n\nДо первого релиза API успел побыть более \"умным\": ClickTone получал DOM-элемент и сам назначал обработчик через `init()`. Перед `1.0.0` я это убрал. Библиотеке не нужно знать, что вызвало `play()`. `click`, клавиатура или внутренняя механика игры — это уже ответственность приложения.\n\n## Спрятать Web Audio, а не событие\n\nВ первом варианте `play()` запускал цепочку загрузки и воспроизведения:\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-устройств в первой версии были обработчики касания, которые вызывали `resume()` для приостановленного `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Теперь экземпляр можно создать заранее, а `AudioContext` создаётся только при первой попытке что-то проиграть. Если после этого `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В `1.8.0` есть ограничение подключения типов: декларация `dist\u002Findex.d.ts` опубликована, но `exports` не позволяет TypeScript в режиме `moduleResolution: bundler` разрешить её через импорт из корня пакета. Это не мешает использовать показанный API из JavaScript.\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- [Исходники clicktone 1.8.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fclicktone\u002Ftree\u002F294916a2f5f5ab781510ee67a2f6a3557afec3b3)","odin-play-dlya-zvuka-v-mini-igrah","2025-04-17","2026-10-11T16:41:38.238Z",[],{"id":132,"documentId":133,"title":134,"content":135,"slug":136,"author":10,"displayDate":137,"publishedAt":138,"tags":139},111,"t5o8wrsruzuu542084uuz63y","Дорабатываю Iconly изнутри","В `2.0.0` способ использования Iconly почти не изменился. Я пересмотрел внутреннюю реализацию: поиск контейнера, вставку 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Я хотел сохранить привычный способ загрузки иконок и переработать внутреннюю реализацию.\n\nTypeScript появился ещё в `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` я ищу и проверяю контейнер в конструкторе:\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После выполнения конструктора контейнер хранится как `HTMLElement`, а не `string | HTMLElement`.\n\nЗдесь я сознательно поменял поведение. Невалидный селектор теперь не означает \"ладно, положим в `body`\". Он означает ошибку конфигурации.\n\nМне хотелось, чтобы остальной код работал с готовым DOM-элементом и не проверял тип контейнера заново.\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Теперь SVG разбирается на отдельном этапе, и ошибку парсинга можно обнаружить до вставки в основной документ.\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` или сырого объекта ошибки: сообщения показывают, на каком этапе возникла проблема. Но в опубликованной сборке `2.0.1` Terser удаляет вызовы `console`, поэтому до пользователя npm-пакета этот вывод не доходит.\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` я уточнил структуру `exports`: корневой экспорт оформлен явно через `\".\"`, а `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- [Исходники iconly 2.0.1](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ficonly\u002Ftree\u002F94c5e7a091a1dc52e9e5d32cf294eeeb686758e9)","dorabatyvayu-iconly-iznutri","2025-03-10","2026-10-11T16:20:51.845Z",[],{"id":141,"documentId":142,"title":143,"content":144,"slug":145,"author":10,"displayDate":146,"publishedAt":147,"tags":148},107,"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` не отменялись, а после повторного подключения наблюдение не возобновлялось.\n\nДля изолированного компонента такая схема удобна. Во фреймворке появляется второй жизненный цикл: приложение управляет компонентом страницы, браузер — Custom Element внутри него. Инициализацию и очистку приходится согласовывать между ними.\n\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-speed=\"20\"\n  data-mc-direction=\"auto\"\n>\n  \u003Cdiv class=\"marquee-group\">\n    \u003Cspan>Primary\u003C\u002Fspan>\n    \u003Cspan>Secondary\u003C\u002Fspan>\n    \u003Cspan>Tertiary\u003C\u002Fspan>\n  \u003C\u002Fdiv>\n\u003C\u002Fdiv>\n```\n\nВместо регистрации HTML-тега нужно создать экземпляр класса и вызвать `init()`. В примере используется объект настроек, появившийся в `4.1.0`:\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В `4.0.0` API принимал элемент или селектор напрямую. Вызов с объектом `{ element }` немного длиннее, зато его проще расширять, не добавляя позиционные аргументы.\n\nВ версиях `4.1.0`–`4.5.0` включительно отсутствующий элемент приводил к раннему выходу из конструктора и оставлял частично созданный экземпляр. В `4.6.0` форма `{ element }` сохранилась, но ошибка снова стала явной: конструктор бросает `Target element not found`.\n\nЯвные `init()` и `destroy()` можно привязать к монтированию и размонтированию компонента во фреймворке. Удаление DOM-узла само по себе больше не запускает очистку, поэтому `destroy()` должен вызвать пользователь. Без этого `ResizeObserver` и ScrollTrigger продолжат жить после удаления элемента.\n\nОчистка в `4.6.0` тоже остаётся неполной: отложенный кадр debounce и контексты `matchMedia` не отменяются, а повторный `init()` после `destroy()` не возобновляет наблюдение за размерами.\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()` больше не требовался глобальный `ScrollTrigger`. Регистрация плагина в самом 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Такой тип утверждает, что допустимого значения не существует. JavaScript-код ожидал 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` меньшее число означало более быструю анимацию, большее — более медленную. `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, а `element` описан как строковый селектор или `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\nVite оставляет `gsap` и `gsap\u002FScrollTrigger` внешними модулями, поэтому в сборку `marquee-content` не встраивается собственная копия GSAP. Пользователю по-прежнему нужно установить и зарегистрировать зависимость.\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\n## Материалы\n\n- [Исходники marquee-content 4.6.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fmarquee-content\u002Ftree\u002F6fd9110166eff8469a2515044f55a37a92ba385e)","custom-element-uhodit-type-script-prihodit","2025-03-09","2026-10-11T15:48:59.811Z",[],{"id":150,"documentId":151,"title":152,"content":153,"slug":154,"author":10,"displayDate":146,"publishedAt":155,"tags":156},119,"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`. Даже в стилях пакета закрывающий переход `transform` длится `550ms`, а `opacity` и фон — `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\nДекларация `dist\u002Findex.d.ts` в `1.5.1` есть, но `exports` не позволяет TypeScript в режиме `moduleResolution: bundler` разрешить её через корневой импорт. Показанный сценарий работает из JavaScript, а подключение типов остаётся ограничением этого пакета.\n\n## Материалы\n\n- [Исходники dialog-lite 1.5.1](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fdialog-lite\u002Ftree\u002F0461d477bfa30b6ad54fcd99c83e81c701480faa)","odin-kontroller-vmesto-dialogov-pod-kazhdyj-proekt","2026-10-11T16:37:43.269Z",[],{"id":158,"documentId":159,"title":160,"content":161,"slug":162,"author":10,"displayDate":163,"publishedAt":164,"tags":165},110,"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Проблема здесь не техническая, а эксплуатационная. `revision` нужно было обновлять отдельно от самого файла. Изменил `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`, а изменение длины SVG-строки автоматически обновляло сохранённую копию.\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В текущей реализации `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` я ещё раз переработал расположение вызовов транзакций. Семантика хранения не изменилась.\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- [Исходники iconly 1.5.1](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Ficonly\u002Ftree\u002Ff9bca4b696aaf39cf077c5be64fad742f02c7fb1)","svg-sprajt-v-local-storage","2024-06-02","2026-10-11T16:18:28.284Z",[],{"id":167,"documentId":168,"title":169,"content":170,"slug":171,"author":10,"displayDate":172,"publishedAt":173,"tags":174},108,"ewuzaa0hw5kfc6gzqph0k67u","Бесконечная анимация, конечный жизненный цикл","Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.\n\nПри ручных проверках заметил, что `marquee-content` создаёт ощутимую нагрузку. Смотрел поведение в DevTools, проверял компонент на реальных устройствах, несколько раз перечитывал код. Не нашёл одного места, на которое можно указать: \"вот утечка\". Нагрузка складывалась из мелочей: повторной инициализации, новых обработчиков прокрутки, неполной очистки при удалении элемента.\n\n`marquee-content@3.0.0` опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\n\n## API оставался стабильным\n\nВ `3.0.0`, как и в `1.9.1`, компонент подключается как 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()`. Компонент создаёт обёртки, рассчитывает количество клонов, задаёт наклон и запускает 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## Вместо таймера — requestAnimationFrame\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()` и планирует его заново. Прежний debounce тоже отменял предыдущий таймер. Теперь вместо задержки в `150ms` обновление привязано к циклу отрисовки браузера.\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`, пришедшие до вызова `update()`, второй выполняет работу с 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Отложенный кадр внутри debounce тоже не отменяется: `disconnectedCallback()` знает только о второй ступени, `this.af`. Если удалить элемент до выполнения первой, она всё ещё может запланировать пересборку.\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- [Исходники marquee-content 3.0.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fmarquee-content\u002Ftree\u002Fee611bac4b29e7ede56720631c5a71143952be82)","beskonechnaya-animacziya-konechnyj-zhiznennyj-czikl","2023-10-31","2026-10-11T15:49:34.584Z",[],{"id":176,"documentId":177,"title":178,"content":179,"slug":180,"author":10,"displayDate":181,"publishedAt":182,"tags":183},109,"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С положительным `timeScale` анимация воспроизводится вперёд, с отрицательным — назад. `totalProgress(0.5)` помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации. Это нужно, чтобы обратное проигрывание не остановилось сразу на абсолютном начале.\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- Пробовал отделять мобильные устройства через `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` тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. В этой ветви пересборка не привязана к `resize`, который возникает при движении браузерной панели.\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- [Исходники marquee-content 1.0.0](https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fmarquee-content\u002Ftree\u002F8d434b335eab28f5f237b69601ddfd26d5461fd1)","begushhaya-stroka-za-chetyre-dnya","2023-03-24","2026-10-11T15:49:57.499Z",[],{"id":176,"documentId":177,"title":178,"content":179,"slug":180,"author":10,"displayDate":181,"publishedAt":182,"tags":185,"html":186},[],"\u003Cp class=\"body-large\">Бегущая строка выглядит почти оскорбительно простой задачей — положить элементы в ряд, сдвигать их влево или вправо и повторять всё сначала. Примерно так я и начал эксперимент с GSAP, ScrollTrigger и Custom Elements.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Через четыре дня в коде уже были клонирование содержимого, три направления движения, адаптивные границы, пауза за пределами экрана и отдельная логика для изменения размеров на мобильных. Самой простой частью всё ещё оставалось само движение.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Сегодня опубликовал \u003Ccode>marquee-content@1.0.0\u003C\u002Fcode>. Разберу, что вошло в первую стабильную версию, какие решения сработали и где небольшой эксперимент уже успел накопить заметный технический долг.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Минимальная оболочка Custom Element\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:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">marquee-content\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  data-mc-duration\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"20\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  data-mc-direction\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"auto\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  role\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"marquee\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">ul\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Primary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Secondary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Tertiary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">ul\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">marquee-content\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Компонент — автономный Custom Element:\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\"> MarqueeContent\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> extends\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> HTMLElement\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  constructor\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    super\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\">.mm \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">matchMedia\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\">.tl \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">timeline\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\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\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">customElements.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">get\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'marquee-content'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  ||\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> customElements.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">define\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'marquee-content'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, MarqueeContent);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Проверка через \u003Ccode>customElements.get()\u003C\u002Fcode> не даёт заново зарегистрировать элемент при повторном подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM, поэтому стили страницы можно применять к его содержимому напрямую.\u003C\u002Fp>\n\u003Cp class=\"body-large\">У этого решения есть шероховатость. Текущая версия читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до \u003Ccode>connectedCallback()\u003C\u002Fcode>. Демо работает, потому что скрипт регистрирует элемент после разбора разметки. Но при более раннем подключении скрипта инициализация может начаться, пока элемент ещё пустой.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Есть и интеграционный долг — демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет \u003Ccode>gsap\u003C\u002Fcode> зависимостью, но исходный модуль всё равно ждёт глобальные \u003Ccode>gsap\u003C\u002Fcode> и \u003Ccode>ScrollTrigger\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:#F97583\">let\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> requiredQuantity\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\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\">.clientWidth \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild.clientWidth \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 3\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">).\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">toFixed\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>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">for\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">let\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> i \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">; i \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> requiredQuantity; i\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">++\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> item\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> clone\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> item.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">cloneNode\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\">  item.parentNode.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">append\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(clone);\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\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">N\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> round\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">W\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> \u002F\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> w \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 3\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>W\u003C\u002Fcode> — ширина контейнера, \u003Ccode>w\u003C\u002Fcode> — ширина исходного блока, а \u003Ccode>N\u003C\u002Fcode> — итоговое количество блоков вместе с оригиналом.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Например, для контейнера шириной \u003Ccode>960px\u003C\u002Fcode> и блока шириной \u003Ccode>320px\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:#79B8FF\">N\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> round\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">960\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> \u002F\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 320\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> +\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 3\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 6\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\">После клонирования GSAP создаёт одну временную шкалу анимации для всех дочерних элементов:\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\">.tl.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.children, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  duration: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.duration,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  x: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'-100%'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  ease: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'none'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  repeat: \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\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>ease: 'none'\u003C\u002Fcode>) и бесконечное повторение (\u003Ccode>repeat: -1\u003C\u002Fcode>) создают непрерывный цикл, а одинаковые копии скрывают переход.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Направление без второй анимации\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Для \u003Ccode>rtl\u003C\u002Fcode> и \u003Ccode>ltr\u003C\u002Fcode> не нужны две отдельные анимации. Достаточно менять знак \u003Ccode>timeScale\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:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.tl\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  .\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.children, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    duration: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.duration,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    x: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'-100%'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    ease: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'none'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    repeat: \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\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\">\u003Cspan style=\"color:#E1E4E8\">  .\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">timeScale\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.dir \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'ltr'\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> ?\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> -\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> :\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  .\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">totalProgress\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">0.5\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>timeScale\u003C\u002Fcode> анимация воспроизводится вперёд, с отрицательным — назад. \u003Ccode>totalProgress(0.5)\u003C\u002Fcode> помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации. Это нужно, чтобы обратное проигрывание не остановилось сразу на абсолютном начале.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Третье значение, \u003Ccode>auto\u003C\u002Fcode>, связывает направление с прокруткой страницы. Обработчик сравнивает текущий \u003Ccode>pageYOffset\u003C\u002Fcode> с предыдущим и плавно переводит \u003Ccode>timeScale\u003C\u002Fcode> в \u003Ccode>1\u003C\u002Fcode> или \u003Ccode>-1\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\"> orientation\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> window.pageYOffset \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> currentScroll \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">?\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> :\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> -\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\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\"> (orientation \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">!==\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> scrollDirection) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.tl, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    timeScale: orientation,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    overwrite: \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\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">ScrollTrigger решает другую задачу: ставит анимацию на паузу, когда компонент покидает область просмотра (\u003Ccode>viewport\u003C\u002Fcode>), и возобновляет при возвращении. Невидимая анимация продолжала бы расходовать ресурсы без пользы.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Адаптивность через gsap.matchMedia()\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Компонент поддерживает \u003Ccode>data-mc-min\u003C\u002Fcode> и \u003Ccode>data-mc-max\u003C\u002Fcode> по отдельности. Каждый из них превращается в медиазапрос, внутри которого создаются клоны и анимация. Если указать оба, \u003Ccode>min\u003C\u002Fcode> перезапишет запрос для \u003Ccode>max\u003C\u002Fcode>, поэтому полноценного диапазона из двух границ пока нет.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для этого используется \u003Ccode>gsap.matchMedia()\u003C\u002Fcode> из GSAP 3.11. Метод запускает переданную функцию, когда условия медиазапроса выполнены. Когда они перестают выполняться, он откатывает созданные GSAP-анимации и вызывает функцию очистки:\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\">.mm.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.breakpoint, () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  \u002F\u002F Создание клонов и анимации\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    removingClones\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\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Это оказалось удобнее отдельного набора \u003Ccode>matchMedia().addEventListener()\u003C\u002Fcode> и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.\u003C\u002Fp>\n\u003Cp class=\"body-large\">GSAP автоматически откатывает созданные внутри функции анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики \u003Ccode>scroll\u003C\u002Fcode>, \u003Ccode>resize\u003C\u002Fcode> и \u003Ccode>change\u003C\u002Fcode> не снимаются, поэтому очистка жизненного цикла остаётся неполной.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Неприятные сюрпризы\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Первый неприятный сюрприз пришёл от iOS. Изменение видимой области браузера во время прокрутки генерировало \u003Ccode>resize\u003C\u002Fcode>. Обработчик безусловно пересобирал анимацию и заново считал клоны, даже когда ширина не менялась. Повторная инициализация во время прокрутки могла нарушить непрерывность ленты.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Начал с пары экспериментов, уверенный, что быстро решу проблему. Проверял несколько подходов:\u003C\u002Fp>\n\u003Cul class=\"list-large\">\n\u003Cli>Сравнивал новую ширину окна с предыдущей;\u003C\u002Fli>\n\u003Cli>Пробовал отделять мобильные устройства через \u003Ccode>userAgent\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>Слушал изменение ориентации;\u003C\u002Fli>\n\u003Cli>Менял задержку debounce;\u003C\u002Fli>\n\u003Cli>Полностью останавливал анимацию перед повторным клонированием.\u003C\u002Fli>\n\u003C\u002Ful>\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:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.mm.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'(any-pointer: coarse)'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> portrait\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> window.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">matchMedia\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'(orientation: portrait)'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  portrait.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">addEventListener\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'change'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, (\u003C\u002Fspan>\u003Cspan style=\"color:#FFAB70\">event\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\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:#F97583\">!\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">event.matches) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      resetAmin\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\">\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\">.mm.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'(any-pointer: fine)'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  window.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">addEventListener\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'resize'\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\">debounce\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(resetAmin, \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">250\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\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">\u003Ccode>(any-pointer: coarse)\u003C\u002Fcode> включает обработку смены ориентации, а \u003Ccode>(any-pointer: fine)\u003C\u002Fcode> — обычный \u003Ccode>resize\u003C\u002Fcode> с задержкой \u003Ccode>250ms\u003C\u002Fcode>. Запросы не взаимоисключающие — на гибридном устройстве могут сработать оба.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Ветвь \u003Ccode>coarse\u003C\u002Fcode> тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. В этой ветви пересборка не привязана к \u003Ccode>resize\u003C\u002Fcode>, который возникает при движении браузерной панели.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">API версии 1.0\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В первый стабильный API вошли пять атрибутов:\u003C\u002Fp>\n\u003Cul class=\"list-large\">\n\u003Cli>\u003Ccode>data-mc-duration\u003C\u002Fcode> — длительность одного цикла, то есть сдвига на ширину блока, в секундах — по умолчанию \u003Ccode>20\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-direction\u003C\u002Fcode> — \u003Ccode>rtl\u003C\u002Fcode>, \u003Ccode>ltr\u003C\u002Fcode> или \u003Ccode>auto\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-skew\u003C\u002Fcode> — наклон по оси Y;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-min\u003C\u002Fcode> — минимальная ширина для запуска;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-max\u003C\u002Fcode> — максимальная ширина для запуска.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp class=\"body-large\">\u003Ccode>data-mc-min\u003C\u002Fcode> и \u003Ccode>data-mc-max\u003C\u002Fcode> работают как альтернативы, а не как совместный диапазон.\u003C\u002Fp>\n\u003Cp class=\"body-large\">За четыре дня у компонента успело появиться больше обязанностей, чем предполагала исходная идея. Само бесконечное движение занимает несколько строк, а больше всего работы потребовали геометрия, изменение размеров и жизненный цикл Custom Element.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Материалы\u003C\u002Fh2>\n\u003Cul class=\"list-large\">\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fblog\u002F3-11\u002F\">GSAP 3.11: gsap.matchMedia()\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F\">Документация GSAP ScrollTrigger\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FGSAP\u002FTimeline\u002FtotalProgress()\u002F\">Документация GSAP totalProgress()\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fhtml.spec.whatwg.org\u002Fmultipage\u002Fcustom-elements.html\">HTML Standard: Custom Elements\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.w3.org\u002FTR\u002Fmediaqueries-4\u002F#any-input\">Media Queries Level 4: any-pointer\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fux-ui-pro\u002Fmarquee-content\u002Ftree\u002F8d434b335eab28f5f237b69601ddfd26d5461fd1\">Исходники marquee-content 1.0.0\u003C\u002Fa>\u003C\u002Fli>\n\u003C\u002Ful>\n",1791736974858]