<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>karandashov.com – Multiplatform</title><link>https://karandashov.com/tags/multiplatform/</link><description>Recent content in Multiplatform on karandashov.com</description><generator>Hugo -- gohugo.io</generator><language>ru</language><managingEditor>vlad.karandashov.tech@gmail.com (Влад Карандашов)</managingEditor><webMaster>vlad.karandashov.tech@gmail.com (Влад Карандашов)</webMaster><lastBuildDate>Sun, 14 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://karandashov.com/tags/multiplatform/index.xml" rel="self" type="application/rss+xml"/><item><title>Kilua: просим Kotlin сделать вид, что он React</title><link>https://karandashov.com/posts/2026-06-14-kilua/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><author>vlad.karandashov.tech@gmail.com (Влад Карандашов)</author><guid>https://karandashov.com/posts/2026-06-14-kilua/</guid><description>
&lt;p&gt;&lt;img src="https://karandashov.com/posts/2026-06-14-kilua/cover.jpg" alt="Kilua: Kotlin на фронтенде" loading="lazy" /&gt;&lt;/p&gt;
&lt;div class="hx:overflow-x-auto hx:mt-6 hx:flex hx:rounded-lg hx:border hx:py-2 hx:ltr:pr-4 hx:rtl:pl-4 hx:contrast-more:border-current hx:contrast-more:dark:border-current hx:border-blue-200 hx:bg-blue-100 hx:text-blue-900 hx:dark:border-blue-200/30 hx:dark:bg-blue-900/30 hx:dark:text-blue-200"&gt;
&lt;div class="hx:ltr:pl-3 hx:ltr:pr-2 hx:rtl:pr-3 hx:rtl:pl-2"&gt;&lt;svg height=1.2em class="hx:inline-block hx:align-middle" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="hx:w-full hx:min-w-0 hx:leading-7"&gt;
&lt;div class="hx:mt-6 hx:leading-7 hx:first:mt-0"&gt;Эта статья была &lt;a
href="https://habr.com/ru/articles/1046505/"target="_blank" rel="noopener"&gt;опубликована на Habr&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Время от времени в Kotlin-мире появляется новый виток надежды: вдруг web-frontend можно писать на привычном языке.
Обычно такие попытки заканчиваются где-то между &amp;ldquo;интересно&amp;rdquo; и &amp;ldquo;давайте все-таки сделаем на React/Vue&amp;rdquo;.
Но иногда маленький энтузиаст в голове все-таки хочет потыкать палочкой новую штуку.
Так я и добрался до &lt;a
href="https://kilua.dev/"target="_blank" rel="noopener"&gt;Kilua&lt;/a&gt; — нового web-фреймворка для Kotlin, который вырос рядом
с &lt;a
href="https://kvision.io/"target="_blank" rel="noopener"&gt;KVision&lt;/a&gt;, но пошел в сторону Compose-подхода.
С недавних пор он включен в &lt;a
href="https://kotlinlang.org/docs/js-frameworks.html#kilua"target="_blank" rel="noopener"&gt;список рекомендаций Kotlin/Js фреймворков от JetBrains&lt;/a&gt;, поэтому его рассмотрение особенно актуально.&lt;/p&gt;
&lt;p&gt;В качестве полигона сделаем небольшое CRUD-приложение для управления домашней аптечкой: лекарства, места хранения,
теги, сроки годности и сканирование штрихкода камерой. Ничего космического, но достаточно живо, чтобы
посмотреть основные возможности. Полный код лежит в &lt;a
href="https://gitlab.com/VladKarandashov/SalusTrove"target="_blank" rel="noopener"&gt;репозитории на GitLab&lt;/a&gt;.&lt;/p&gt;
&lt;div class="hextra-cards hx:mt-4 hx:gap-4 hx:grid not-prose" style="--hextra-cards-grid-cols: 3;"&gt;
&lt;a
class="hextra-card hx:group hx:flex hx:flex-col hx:justify-start hx:overflow-hidden hx:rounded-lg hx:border hx:border-gray-200 hx:text-current hx:no-underline hx:dark:shadow-none hx:hover:shadow-gray-100 hx:dark:hover:shadow-none hx:shadow-gray-100 hx:active:shadow-sm hx:active:shadow-gray-200 hx:transition-all hx:duration-200 hx:hover:border-gray-300 hx:bg-transparent hx:shadow-xs hx:dark:border-neutral-800 hx:hover:bg-slate-50 hx:hover:shadow-md hx:dark:hover:border-neutral-700 hx:dark:hover:bg-neutral-900"href="https://kilua.dev/"
target="_blank" rel="noreferrer"&gt;&lt;div class="hx:mt-auto"&gt;
&lt;span class="hextra-card-icon hx:flex hx:font-semibold hx:items-start hx:gap-2 hx:pt-4 hx:px-4 hx:text-gray-700 hx:hover:text-gray-900 hx:dark:text-neutral-200 hx:dark:hover:text-neutral-50"&gt;&lt;svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/&gt;&lt;/svg&gt;Kilua&lt;/span&gt;&lt;div class="hextra-card-subtitle hx:line-clamp-3 hx:text-sm hx:font-normal hx:text-gray-500 hx:dark:text-gray-400 hx:px-4 hx:mb-4 hx:mt-2"&gt;Документация и сайт фреймворка&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;
&lt;a
class="hextra-card hx:group hx:flex hx:flex-col hx:justify-start hx:overflow-hidden hx:rounded-lg hx:border hx:border-gray-200 hx:text-current hx:no-underline hx:dark:shadow-none hx:hover:shadow-gray-100 hx:dark:hover:shadow-none hx:shadow-gray-100 hx:active:shadow-sm hx:active:shadow-gray-200 hx:transition-all hx:duration-200 hx:hover:border-gray-300 hx:bg-transparent hx:shadow-xs hx:dark:border-neutral-800 hx:hover:bg-slate-50 hx:hover:shadow-md hx:dark:hover:border-neutral-700 hx:dark:hover:bg-neutral-900"href="https://gitlab.com/VladKarandashov/SalusTrove"
target="_blank" rel="noreferrer"&gt;&lt;div class="hx:mt-auto"&gt;
&lt;span class="hextra-card-icon hx:flex hx:font-semibold hx:items-start hx:gap-2 hx:pt-4 hx:px-4 hx:text-gray-700 hx:hover:text-gray-900 hx:dark:text-neutral-200 hx:dark:hover:text-neutral-50"&gt;&lt;svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"&gt;
&lt;path d="m23.6 9.593l-.033-.086L20.3.98a.851.851 0 0 0-.336-.405a.875.875 0 0 0-1 .054a.875.875 0 0 0-.29.44L16.47 7.818H7.537L5.333 1.07a.857.857 0 0 0-.29-.441a.875.875 0 0 0-1-.054a.859.859 0 0 0-.336.405L.433 9.502l-.032.086a6.066 6.066 0 0 0 2.012 7.01l.01.009l.03.021l4.977 3.727l2.462 1.863l1.5 1.132a1.009 1.009 0 0 0 1.22 0l1.499-1.132l2.461-1.863l5.006-3.75l.013-.01a6.068 6.068 0 0 0 2.01-7.002"/&gt;
&lt;/svg&gt;
SalusTrove&lt;/span&gt;&lt;div class="hextra-card-subtitle hx:line-clamp-3 hx:text-sm hx:font-normal hx:text-gray-500 hx:dark:text-gray-400 hx:px-4 hx:mb-4 hx:mt-2"&gt;Полный код демо-приложения&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h2&gt;Что вообще такое Kilua&lt;span class="hx:absolute hx:-mt-20" id="что-вообще-такое-kilua"&gt;&lt;/span&gt;
&lt;a href="#%d1%87%d1%82%d0%be-%d0%b2%d0%be%d0%be%d0%b1%d1%89%d0%b5-%d1%82%d0%b0%d0%ba%d0%be%d0%b5-kilua" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;&lt;a
href="https://kilua.dev/"target="_blank" rel="noopener"&gt;Kilua&lt;/a&gt; — это open source web-фреймворк для Kotlin. Он использует Compose Multiplatform Runtime
(не путайте с Jetpack Compose для Android или Compose Web, который рисует UI через canvas/Skia). Kilua рендерит
обычный HTML DOM: на странице в итоге живут нормальные &lt;code&gt;div&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt;, &lt;code&gt;input&lt;/code&gt;, CSS и браузерные события. Если
собираем JS target — Kotlin-код буквально превращается в JavaScript bundle.&lt;/p&gt;
&lt;p&gt;Предшественником Kilua был KVision, фреймворк развивает тот же разработчик.
KVision более старый объектно-ориентированный фреймворк для Kotlin/JS: компоненты, биндинги, UI из Kotlin-кода, интеграции с backend.
Kilua выглядит как попытка сделать следующий заход уже с современным Compose runtime: &lt;code&gt;@Composable&lt;/code&gt; функции, &lt;code&gt;remember&lt;/code&gt;,
&lt;code&gt;mutableStateOf&lt;/code&gt;, корутины, возможность собираться и в Kotlin/JS, и в Kotlin/Wasm.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;Подход&lt;/th&gt;
&lt;th style="text-align: left"&gt;KVision&lt;/th&gt;
&lt;th style="text-align: left"&gt;Kilua&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;Модель UI&lt;/td&gt;
&lt;td style="text-align: left"&gt;Объектно-ориентированные компоненты&lt;/td&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;@Composable&lt;/code&gt; функции&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;Runtime&lt;/td&gt;
&lt;td style="text-align: left"&gt;Kotlin/JS UI-фреймворк&lt;/td&gt;
&lt;td style="text-align: left"&gt;Compose Multiplatform Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;Цели сборки&lt;/td&gt;
&lt;td style="text-align: left"&gt;Kotlin/JS&lt;/td&gt;
&lt;td style="text-align: left"&gt;Kotlin/JS и Kotlin/Wasm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;Рендеринг&lt;/td&gt;
&lt;td style="text-align: left"&gt;HTML DOM&lt;/td&gt;
&lt;td style="text-align: left"&gt;HTML DOM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;На момент написания примера актуальная версия фреймворка — &lt;code&gt;0.0.34&lt;/code&gt;: проект уже вполне рабочий, но активная разработка еще
идет.&lt;/p&gt;
&lt;p&gt;Почему не просто KVision 10? Тут можно только осторожно интерпретировать, но причина выглядит довольно земной. Если у
вас был объектный Kotlin/JS-фреймворк, а вы хотите перейти к Compose-модели, Wasm, SSR и новому API — это уже не
косметический ремонт. Новое имя в такой ситуации даже полезно: меньше иллюзии, что миграция будет состоять из трех импортов и молитвы.&lt;/p&gt;
&lt;p&gt;Из заметных фич Kilua на сайте сейчас выделяются готовые компоненты, поддержка Bootstrap и Tailwind, router, HTTP
client, SSR, статический export и &lt;a
href="https://kilua.gitbook.io/kilua-rpc-guide"target="_blank" rel="noopener"&gt;Kilua RPC&lt;/a&gt;.&lt;/p&gt;
&lt;details class="hx:last-of-type:mb-0 hx:rounded-lg hx:bg-neutral-50 hx:dark:bg-neutral-800 hx:p-2 hx:mt-4 hx:group" &gt;
&lt;summary class="hx:flex hx:items-center hx:cursor-pointer hx:select-none hx:list-none hx:p-1 hx:rounded-sm hx:transition-colors hx:hover:bg-gray-100 hx:dark:hover:bg-neutral-800 hx:before:mr-1 hx:before:inline-block hx:before:transition-transform hx:before:content-[''] hx:dark:before:invert hx:rtl:before:rotate-180 hx:group-open:before:rotate-90"&gt;
&lt;strong class="hx:text-lg"&gt;Kilua RPC и gRPC - это не одно и то же&lt;/strong&gt;
&lt;/summary&gt;
&lt;div class="hx:p-2 hx:overflow-hidden"&gt;
&lt;p&gt;Kilua RPC особенно интересен для fullstack Kotlin: можно описывать контракты в общем коде и связывать frontend с backend на Ktor, Spring Boot, Micronaut, Javalin, Jooby или Vert.x.&lt;/p&gt;
&lt;p&gt;Совместимость с gRPC в документации не заявлена: Kilua RPC — отдельная Kotlin-first RPC библиотека, а не gRPC transport поверх &lt;code&gt;.proto&lt;/code&gt;, HTTP/2 и protobuf. Если в проекте уже есть gRPC-контракты, их придется интегрировать отдельно.&lt;/p&gt;
&lt;p&gt;В текущем примере RPC не используется: там обычный REST через &lt;code&gt;fetch&lt;/code&gt;, чтобы не усложнять.&lt;/p&gt;
&lt;/div&gt;
&lt;/details&gt;
&lt;h2&gt;Зачем писать frontend на Kotlin?&lt;span class="hx:absolute hx:-mt-20" id="зачем-писать-frontend-на-kotlin"&gt;&lt;/span&gt;
&lt;a href="#%d0%b7%d0%b0%d1%87%d0%b5%d0%bc-%d0%bf%d0%b8%d1%81%d0%b0%d1%82%d1%8c-frontend-%d0%bd%d0%b0-kotlin" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Самый честный ответ: не всегда нужно.&lt;/p&gt;
&lt;p&gt;Если команда уверенно пишет на React/Vue/Svelte, у нее уже есть дизайн-система, Storybook, тесты, CI/CD и привычные
инструменты, то приходить туда с Kotlin-фреймворком в руках надо очень аккуратно.
Мир frontend — это не только язык, но и экосистема, browser API, CSS, accessibility, сборка, линтеры, пакеты,
devtools и соседний чат, где кто-то уже третий час спорит про &lt;code&gt;z-index&lt;/code&gt;.
Приносить сюда Kotlin означает еще один (возможно лишний) промежуточный шаг, в котором что-то может пойти не так.&lt;/p&gt;
&lt;p&gt;Но у Kotlin на фронте все же есть свой смысл. Например:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;небольшой внутренний инструмент для Kotlin-команды;&lt;/li&gt;
&lt;li&gt;pet project, где хочется один язык и знакомый Gradle;&lt;/li&gt;
&lt;li&gt;fullstack-приложение с общими моделями, сериализацией и валидацией;&lt;/li&gt;
&lt;li&gt;команда, которой ближе Compose-мышление, чем классический JS-фреймворк;&lt;/li&gt;
&lt;li&gt;желание потрогать Kotlin/Wasm без полного ухода в экспериментальную лабораторию.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="hx:overflow-x-auto hx:mt-6 hx:flex hx:rounded-lg hx:border hx:py-2 hx:ltr:pr-4 hx:rtl:pl-4 hx:contrast-more:border-current hx:contrast-more:dark:border-current hx:border-amber-200 hx:bg-amber-100 hx:text-amber-900 hx:dark:border-amber-200/30 hx:dark:bg-amber-900/30 hx:dark:text-amber-200"&gt;
&lt;div class="hx:ltr:pl-3 hx:ltr:pr-2 hx:rtl:pr-3 hx:rtl:pl-2"&gt;&lt;svg height=1.2em class="hx:inline-block hx:align-middle" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="hx:w-full hx:min-w-0 hx:leading-7"&gt;
&lt;div class="hx:mt-6 hx:leading-7 hx:first:mt-0"&gt;Kilua не отменяет HTML, CSS и JavaScript. Он дает Kotlin-слой поверх браузера, но не освобождает от понимания самого браузера.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Kilua не отменяет HTML, CSS и JavaScript. Это важный момент. Код на Kilua часто выглядит как Kotlin-версия HTML+JS:
&lt;code&gt;vPanel&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;className&lt;/code&gt;, &lt;code&gt;onInput&lt;/code&gt;. Да, это Kotlin. Но вам все равно надо понимать, как работает &lt;code&gt;input&lt;/code&gt;,
почему CSS поехал на мобильном экране и почему событие случилось не тогда, когда вы морально были к нему готовы.&lt;/p&gt;
&lt;p&gt;Если хочется совсем не думать про браузер, ближе будет &lt;a
href="https://vaadin.com/docs/latest/flow/what-is-flow"target="_blank" rel="noopener"&gt;Vaadin Flow&lt;/a&gt;:
там UI живет в основном на сервере, вы собираете приложение из Java-компонентов, а Vaadin синхронизирует это с
браузером. Цена другая: больше завязки на сервер, состояние сессии, сетевое взаимодействие. Kilua — это все-таки
клиентское приложение. Просто написанное на Kotlin и собранное в браузерный bundle.&lt;/p&gt;
&lt;h2&gt;Пробуем Kilua в деле&lt;span class="hx:absolute hx:-mt-20" id="пробуем-kilua-в-деле"&gt;&lt;/span&gt;
&lt;a href="#%d0%bf%d1%80%d0%be%d0%b1%d1%83%d0%b5%d0%bc-kilua-%d0%b2-%d0%b4%d0%b5%d0%bb%d0%b5" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Официальная документация предлагает два пути: поставить Kilua Project Wizard в IntelliJ IDEA или скопировать template
project.
Если в проекте уже есть backend-модуль, можно просто положить frontend рядом. Это позволит на этапе сборки положить собранный
js-bundle прямо в static ресурсы backend, получив единое приложение.&lt;/p&gt;
&lt;p&gt;Сам frontend-модуль использует Kotlin Multiplatform, Compose compiler/plugin и Kilua:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;view-frontend/build.gradle.kts&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;plugins {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Kotlin Multiplatform дает JS/Wasm targets.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; kotlin(&lt;span style="color:#e6db74"&gt;&amp;#34;multiplatform&amp;#34;&lt;/span&gt;) version &lt;span style="color:#e6db74"&gt;&amp;#34;2.3.21&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Нужен для kotlinx.serialization в DTO и REST-клиенте.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; kotlin(&lt;span style="color:#e6db74"&gt;&amp;#34;plugin.serialization&amp;#34;&lt;/span&gt;) version &lt;span style="color:#e6db74"&gt;&amp;#34;2.3.21&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Compose runtime: @Composable, remember, mutableStateOf.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; id(&lt;span style="color:#e6db74"&gt;&amp;#34;org.jetbrains.compose&amp;#34;&lt;/span&gt;) version &lt;span style="color:#e6db74"&gt;&amp;#34;1.11.0&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; id(&lt;span style="color:#e6db74"&gt;&amp;#34;org.jetbrains.kotlin.plugin.compose&amp;#34;&lt;/span&gt;) version &lt;span style="color:#e6db74"&gt;&amp;#34;2.3.21&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Сам Kilua и его Gradle-задачи.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; id(&lt;span style="color:#e6db74"&gt;&amp;#34;dev.kilua&amp;#34;&lt;/span&gt;) version &lt;span style="color:#e6db74"&gt;&amp;#34;0.0.34&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;kotlin {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; js(IR) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; useEsModules()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; browser {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; commonWebpackConfig {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cssSupport {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; enabled = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; outputFileName = &lt;span style="color:#e6db74"&gt;&amp;#34;main.bundle.js&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; sourceMaps = &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; binaries.executable()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; compilerOptions {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; target.&lt;span style="color:#66d9ef"&gt;set&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;es2015&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; sourceSets {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; commonMain.dependencies {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; implementation(&lt;span style="color:#e6db74"&gt;&amp;#34;dev.kilua:kilua:0.0.34&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; implementation(&lt;span style="color:#e6db74"&gt;&amp;#34;dev.kilua:kilua-bootstrap:0.0.34&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; implementation(&lt;span style="color:#e6db74"&gt;&amp;#34;dev.kilua:kilua-bootstrap-icons:0.0.34&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Для разработки можно запускать JS target командой &lt;code&gt;./gradlew -t :view-frontend:jsBrowserDevelopmentRun&lt;/code&gt;. После старта
dev-сервер обычно доступен на &lt;code&gt;http://localhost:3000&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Для production-сборки достаточно &lt;code&gt;./gradlew :view-frontend:jsBrowserDistribution&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Результат появляется в &lt;code&gt;view-frontend/build/dist/js/productionExecutable&lt;/code&gt;: &lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;app.css&lt;/code&gt;, &lt;code&gt;main.bundle.js&lt;/code&gt;,
шрифты и прочие ресурсы. В моем случае &lt;code&gt;main.bundle.js&lt;/code&gt; получился около 1.6 MB. Для маленького CRUD это не &amp;ldquo;вау, как
компактно&amp;rdquo;, но и не повод сразу звонить в комитет по чрезвычайным ситуациям.&lt;/p&gt;
&lt;p&gt;Отдельная бытовая деталь: Kotlin/JS все равно требует Node/npm — это нужно учитывать в CI/CD. В проекте
используется &lt;code&gt;kotlin-js-store/package-lock.json&lt;/code&gt;, а после изменения frontend-зависимостей или версии Kotlin/JS-плагина
нужно обновлять lock-файл командой &lt;code&gt;./gradlew kotlinUpgradePackageLock&lt;/code&gt; (ручные правки или напрямую через npm просто сломают билд).&lt;/p&gt;
&lt;h3&gt;Скелет приложения&lt;span class="hx:absolute hx:-mt-20" id="скелет-приложения"&gt;&lt;/span&gt;
&lt;a href="#%d1%81%d0%ba%d0%b5%d0%bb%d0%b5%d1%82-%d0%bf%d1%80%d0%b8%d0%bb%d0%be%d0%b6%d0%b5%d0%bd%d0%b8%d1%8f" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Для небольшого&lt;/strong&gt; SPA можно использовать примерно такую структуру:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;view-frontend/&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;view-frontend/
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; src/commonMain/
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; kotlin/com/example/view/
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; App.kt
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; model/Models.kt
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; api/MedicineApi.kt
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; store/AppStore.kt
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ui/Screens.kt
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; platform/Platform.kt
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; resources/
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; index.html
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; app.css
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; src/jsMain/
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; kotlin/com/example/view/platform/Platform.js.kt&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;code&gt;index.html&lt;/code&gt; почти пустой. Он только подключает CSS, кладет корневой элемент и загружает bundle:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;src/commonMain/resources/index.html&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;html&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;lang&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;ru&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;body&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;id&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;root&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;script&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;main.bundle.js&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style="color:#f92672"&gt;script&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;body&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;html&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Точка входа в Kilua тоже довольно компактная:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;App.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;MedicineFrontend&lt;/span&gt; : Application() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;override&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;start&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; root(&lt;span style="color:#e6db74"&gt;&amp;#34;root&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; MedicineApp()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; startApplication(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;::&lt;/span&gt;MedicineFrontend,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; BootstrapModule,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; BootstrapCssModule,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; BootstrapIconsModule,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; CoreModule,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; )
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Здесь &lt;code&gt;root(&amp;quot;root&amp;quot;)&lt;/code&gt; цепляется к &lt;code&gt;div id=&amp;quot;root&amp;quot;&lt;/code&gt; из HTML, а дальше начинается обычный Compose-подход: &lt;code&gt;@Composable&lt;/code&gt;
функции, состояние, перерисовка при изменении state.&lt;/p&gt;
&lt;h3&gt;Запросы к backend&lt;span class="hx:absolute hx:-mt-20" id="запросы-к-backend"&gt;&lt;/span&gt;
&lt;a href="#%d0%b7%d0%b0%d0%bf%d1%80%d0%be%d1%81%d1%8b-%d0%ba-backend" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Внутри приложения есть несколько сущностей: лекарство, место хранения и тег.
Модели лежат в &lt;code&gt;commonMain&lt;/code&gt;, потому что frontend у нас общий для потенциальных JS/Wasm targets. В реальном
fullstack-проекте часть этих DTO можно было бы вынести в общий модуль между backend и frontend. А если подключить Kilua
RPC, то можно пойти дальше и не писать ручной REST-клиент. Но для первого знакомства обычный &lt;code&gt;fetch&lt;/code&gt; даже полезнее: проще понять, что происходит.&lt;/p&gt;
&lt;p&gt;API-слой выглядит примерно так:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;MedicineApi.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;MedicineApi&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; json = Json {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ignoreUnknownKeys = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; encodeDefaults = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;loadMedicines&lt;/span&gt;(): List&amp;lt;MedicineDto&amp;gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;get&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;/api/medicines/search&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;inline&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &amp;lt;&lt;span style="color:#66d9ef"&gt;reified&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;T&lt;/span&gt;&amp;gt; &lt;span style="color:#a6e22e"&gt;get&lt;/span&gt;(path: String): T {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; response = httpRequest(path)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (!response.successful) error(&lt;span style="color:#e6db74"&gt;&amp;#34;Ошибка сервера (&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${response.status}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;)&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; json.decodeFromString(response.body)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Для JS-only приложения это вполне прямой путь.
Если хочется писать один и тот же код под JS и Wasm, придется аккуратнее работать с &lt;code&gt;JsAny&lt;/code&gt;, &lt;code&gt;kotlin-wrappers&lt;/code&gt;
и рекомендациями из разделов &lt;a
href="https://kilua.dev/development-guide/browser-apis"target="_blank" rel="noopener"&gt;Browser APIs&lt;/a&gt; и &lt;a
href="https://kilua.dev/development-guide/interoperability-with-javascript"target="_blank" rel="noopener"&gt;Interoperability with JavaScript&lt;/a&gt; в документации Kilua.&lt;/p&gt;
&lt;h3&gt;Где держать состояние&lt;span class="hx:absolute hx:-mt-20" id="где-держать-состояние"&gt;&lt;/span&gt;
&lt;a href="#%d0%b3%d0%b4%d0%b5-%d0%b4%d0%b5%d1%80%d0%b6%d0%b0%d1%82%d1%8c-%d1%81%d0%be%d1%81%d1%82%d0%be%d1%8f%d0%bd%d0%b8%d0%b5" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Для небольшого приложения можно не тащить отдельный state manager. Compose runtime уже дает нормальную модель состояния:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;AppStore.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;AppStore&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; api: MedicineApi = MedicineApi(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; medicines &lt;span style="color:#66d9ef"&gt;by&lt;/span&gt; mutableStateOf&amp;lt;List&amp;lt;MedicineDto&amp;gt;&amp;gt;(emptyList())
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;set&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; loading &lt;span style="color:#66d9ef"&gt;by&lt;/span&gt; mutableStateOf(&lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;set&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;refreshMedicines&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; medicines = api.loadMedicines().sortedBy { &lt;span style="color:#66d9ef"&gt;it&lt;/span&gt;.expirationDate }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;T&lt;/span&gt;&amp;gt; &lt;span style="color:#a6e22e"&gt;runLoading&lt;/span&gt;(block: &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; () &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; T): T {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; loading = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;try&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; block()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;finally&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; loading = &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Компонент создает store через &lt;code&gt;remember&lt;/code&gt;, дергает &lt;code&gt;initialize()&lt;/code&gt; в &lt;code&gt;LaunchedEffect&lt;/code&gt;, а дальше UI сам реагирует на
изменения:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;App.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@Composable&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;IComponent&lt;/span&gt;.MedicineApp() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; store = remember { AppStore() }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; screen &lt;span style="color:#66d9ef"&gt;by&lt;/span&gt; remember { mutableStateOf(&lt;span style="color:#a6e22e"&gt;AppScreen&lt;/span&gt;.Medicines) }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; LaunchedEffect(Unit) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; store.refreshMedicines()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; div(&lt;span style="color:#e6db74"&gt;&amp;#34;app-shell&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; AppHeader(screen, store.loading)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; main(&lt;span style="color:#e6db74"&gt;&amp;#34;app-main&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;when&lt;/span&gt; (screen) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;AppScreen&lt;/span&gt;.Medicines &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; MedicinesScreen(store)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;AppScreen&lt;/span&gt;.Locations &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; LocationsScreen(store)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;AppScreen&lt;/span&gt;.Tags &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; TagsScreen(store)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; BottomNavigation(screen) { screen = &lt;span style="color:#66d9ef"&gt;it&lt;/span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3&gt;Верстка&lt;span class="hx:absolute hx:-mt-20" id="верстка"&gt;&lt;/span&gt;
&lt;a href="#%d0%b2%d0%b5%d1%80%d1%81%d1%82%d0%ba%d0%b0" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Внутри экрана все похоже на обычное декларативное UI-программирование:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;Screens.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@Composable&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;IComponent&lt;/span&gt;.MedicinesScreen(store: AppStore) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; search &lt;span style="color:#66d9ef"&gt;by&lt;/span&gt; remember { mutableStateOf(&lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt;) }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; visibleMedicines = store.medicines.filter {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; search.isBlank() &lt;span style="color:#f92672"&gt;||&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;it&lt;/span&gt;.title.contains(search.trim(), ignoreCase = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; vPanel(className = &lt;span style="color:#e6db74"&gt;&amp;#34;screen-stack&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; h2t(&lt;span style="color:#e6db74"&gt;&amp;#34;Лекарства&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;screen-title&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; text(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;value&lt;/span&gt; = search,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; type = &lt;span style="color:#a6e22e"&gt;InputType&lt;/span&gt;.Search,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; placeholder = &lt;span style="color:#e6db74"&gt;&amp;#34;Поиск по названию&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; className = &lt;span style="color:#e6db74"&gt;&amp;#34;form-control search-input&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; onInput { search = &lt;span style="color:#66d9ef"&gt;value&lt;/span&gt;.orEmpty() }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (visibleMedicines.isEmpty()) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; EmptyState(&lt;span style="color:#e6db74"&gt;&amp;#34;Ничего не найдено&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;bi bi-search&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; vPanel(className = &lt;span style="color:#e6db74"&gt;&amp;#34;medicine-list&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; visibleMedicines.forEach { medicine &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; MedicineCard(medicine)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Самое приятное тут — обычный Kotlin DSL: рефакторинг, типы, sealed-классы, extension-функции, корутины, сериализация. Самое
отрезвляющее — это все еще верстка: &lt;code&gt;vPanel&lt;/code&gt;, &lt;code&gt;hPanel&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;, &lt;code&gt;span&lt;/code&gt;, &lt;code&gt;className&lt;/code&gt;, Bootstrap-классы и CSS никуда не
делись.&lt;/p&gt;
&lt;p&gt;Форма создания лекарства состоит из обычных Kilua-компонентов: &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;textArea&lt;/code&gt;, кнопки, модальное
окно. Состояние формы удобно держать отдельным data class, а в нем сделать метод &lt;code&gt;toRequest()&lt;/code&gt;, который валидирует
обязательные поля и превращает форму в DTO для backend. Сам UI формы получается довольно механическим:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;MedicineForm.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;FormField(&lt;span style="color:#e6db74"&gt;&amp;#34;Название&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; text(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;value&lt;/span&gt; = form.title,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; placeholder = &lt;span style="color:#e6db74"&gt;&amp;#34;Например: Парацетамол&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; required = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; className = &lt;span style="color:#e6db74"&gt;&amp;#34;form-control&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; onInput { form = form.copy(title = &lt;span style="color:#66d9ef"&gt;value&lt;/span&gt;.orEmpty()) }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3&gt;Доступ к браузеру: сканер штрихкодов&lt;span class="hx:absolute hx:-mt-20" id="доступ-к-браузеру-сканер-штрихкодов"&gt;&lt;/span&gt;
&lt;a href="#%d0%b4%d0%be%d1%81%d1%82%d1%83%d0%bf-%d0%ba-%d0%b1%d1%80%d0%b0%d1%83%d0%b7%d0%b5%d1%80%d1%83-%d1%81%d0%ba%d0%b0%d0%bd%d0%b5%d1%80-%d1%88%d1%82%d1%80%d0%b8%d1%85%d0%ba%d0%be%d0%b4%d0%be%d0%b2" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Один из полезных тестов для такого фреймворка — попробовать не только кнопки и формы, но и реальный browser API. В
аптечном приложении я добавил сканирование штрихкода камерой. Для этого используется &lt;code&gt;BarcodeDetector&lt;/code&gt; и
&lt;code&gt;navigator.mediaDevices.getUserMedia&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;В общем коде оставляем &lt;code&gt;expect&lt;/code&gt;-объявления:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;Platform.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;data&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;BarcodeScan&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; barcode: String,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;expect&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;isBarcodeScannerSupported&lt;/span&gt;(): Boolean
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;expect&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;scanBarcodeFromCamera&lt;/span&gt;(videoElementId: String): BarcodeScan?
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;expect&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;stopBarcodeScanner&lt;/span&gt;()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;А в &lt;code&gt;jsMain&lt;/code&gt; лежит JS-реализация:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;Platform.js.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;actual&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;isBarcodeScannerSupported&lt;/span&gt;(): Boolean {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; js(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#39;BarcodeDetector&amp;#39; in window &amp;amp;&amp;amp; &amp;#34;&lt;/span&gt; +
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;!!navigator.mediaDevices &amp;amp;&amp;amp; &amp;#34;&lt;/span&gt; +
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;!!navigator.mediaDevices.getUserMedia&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ) &lt;span style="color:#66d9ef"&gt;as&lt;/span&gt; Boolean
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;actual&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;suspend&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;scanBarcodeFromCamera&lt;/span&gt;(videoElementId: String): BarcodeScan? {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; video = js(&lt;span style="color:#e6db74"&gt;&amp;#34;document.getElementById(videoElementId)&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; constraints = js(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;({ video: { facingMode: { ideal: &amp;#39;environment&amp;#39; } }, audio: false })&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; )
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; activeStream = js(&lt;span style="color:#e6db74"&gt;&amp;#34;navigator.mediaDevices.getUserMedia(constraints)&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .unsafeCast&amp;lt;Promise&amp;lt;&lt;span style="color:#66d9ef"&gt;dynamic&lt;/span&gt;&amp;gt;&amp;gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .await()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; video.srcObject = activeStream
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; video.play().unsafeCast&amp;lt;Promise&amp;lt;&lt;span style="color:#66d9ef"&gt;dynamic&lt;/span&gt;&amp;gt;&amp;gt;().await()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; detector = js(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;new BarcodeDetector({ formats: [&amp;#39;ean_13&amp;#39;, &amp;#39;ean_8&amp;#39;, &amp;#39;upc_a&amp;#39;, &amp;#39;code_128&amp;#39;, &amp;#39;qr_code&amp;#39;] })&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; )
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;while&lt;/span&gt; (activeScan) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; codes = detector.detect(video).unsafeCast&amp;lt;Promise&amp;lt;&lt;span style="color:#66d9ef"&gt;dynamic&lt;/span&gt;&amp;gt;&amp;gt;().await()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (codes.length &amp;gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; stopBarcodeScanner()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; BarcodeScan(codes[&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;].rawValue &lt;span style="color:#66d9ef"&gt;as&lt;/span&gt; String)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; delay(&lt;span style="color:#ae81ff"&gt;350&lt;/span&gt;.milliseconds)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;null&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Это место хорошо показывает реальность Kotlin-фронтенда. Пока вы живете внутри компонентов, все выглядит почти уютно.
Как только надо поговорить с браузером напрямую — вы снова рядом с JavaScript interop.&lt;/p&gt;
&lt;p&gt;Зато в UI эта функция подключается вполне чисто:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div class="hextra-code-filename not-prose" dir="auto"&gt;ScannerModal.kt&lt;/div&gt;&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;div(&lt;span style="color:#e6db74"&gt;&amp;#34;scanner-preview&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; video(&lt;span style="color:#e6db74"&gt;&amp;#34;scanner-video&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;barcode-scanner-video&amp;#34;&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; attribute(&lt;span style="color:#e6db74"&gt;&amp;#34;autoplay&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; attribute(&lt;span style="color:#e6db74"&gt;&amp;#34;muted&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; attribute(&lt;span style="color:#e6db74"&gt;&amp;#34;playsinline&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; div(&lt;span style="color:#e6db74"&gt;&amp;#34;scanner-frame&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;bsButton(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;Сканировать&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;bi bi-upc-scan&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; disabled = scanning &lt;span style="color:#f92672"&gt;||&lt;/span&gt; &lt;span style="color:#f92672"&gt;!is&lt;/span&gt;BarcodeScannerSupported(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; onClick {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; scanning = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; scanRequest &lt;span style="color:#f92672"&gt;+=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-8"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Скопировать код"
aria-label="Скопировать код"
data-copied-label="Скопировано!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Дальше в &lt;code&gt;LaunchedEffect&lt;/code&gt; можно дождаться результата и положить штрихкод в форму.&lt;/p&gt;
&lt;h2&gt;Что понравилось&lt;span class="hx:absolute hx:-mt-20" id="что-понравилось"&gt;&lt;/span&gt;
&lt;a href="#%d1%87%d1%82%d0%be-%d0%bf%d0%be%d0%bd%d1%80%d0%b0%d0%b2%d0%b8%d0%bb%d0%be%d1%81%d1%8c" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Kotlin остается главным языком.&lt;/strong&gt; DTO, enum, extension-функции, корутины, &lt;code&gt;kotlinx.serialization&lt;/code&gt;, Gradle-модули — все это остается в привычной зоне. Если вы backend-разработчик на Kotlin, Kilua не выглядит чужим.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compose runtime.&lt;/strong&gt; Не сам Compose UI, а именно модель состояния и &lt;code&gt;@Composable&lt;/code&gt; функции. После Android/Compose Multiplatform это ощущается естественно: состояние меняется, UI пересобирается, локальное состояние живет через &lt;code&gt;remember&lt;/code&gt;, side effects уходят в &lt;code&gt;LaunchedEffect&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Обычный DOM.&lt;/strong&gt; Canvas-рендеринг хорош для своих задач, но для web-приложений обычные HTML-элементы дают нормальную работу с CSS, accessibility, devtools, SEO и интеграцией с браузером.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JavaScript не спрятан под ковер.&lt;/strong&gt; Есть интероп, работа с ресурсами, Bootstrap/Tailwind, RPC и SSR.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Что смущает&lt;span class="hx:absolute hx:-mt-20" id="что-смущает"&gt;&lt;/span&gt;
&lt;a href="#%d1%87%d1%82%d0%be-%d1%81%d0%bc%d1%83%d1%89%d0%b0%d0%b5%d1%82" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Применимость пока не очевидна. Для большинства публичных frontend-проектов React/Vue/Svelte будут прагматичнее: больше
экосистема, больше специалистов, больше готовых решений, проще найти ответ на странную ошибку из глубин сборки.&lt;/p&gt;
&lt;p&gt;Код на Kilua все равно остается frontend-кодом. Да, синтаксис Kotlin. Но мышление часто такое же: собрать DOM, повесить
обработчики, назначить классы, написать CSS, разобраться с browser API. Если человек не знает HTML/CSS/JS, Kilua не
телепортирует его сразу в senior frontend. Скорее даст возможность учиться этому из Kotlin-кода, что само по себе
неплохо, но чудом не является.&lt;/p&gt;
&lt;details class="hx:last-of-type:mb-0 hx:rounded-lg hx:bg-neutral-50 hx:dark:bg-neutral-800 hx:p-2 hx:mt-4 hx:group" &gt;
&lt;summary class="hx:flex hx:items-center hx:cursor-pointer hx:select-none hx:list-none hx:p-1 hx:rounded-sm hx:transition-colors hx:hover:bg-gray-100 hx:dark:hover:bg-neutral-800 hx:before:mr-1 hx:before:inline-block hx:before:transition-transform hx:before:content-[''] hx:dark:before:invert hx:rtl:before:rotate-180 hx:group-open:before:rotate-90"&gt;
&lt;strong class="hx:text-lg"&gt;Где появляются дополнительные уровни сложности&lt;/strong&gt;
&lt;/summary&gt;
&lt;div class="hx:p-2 hx:overflow-hidden"&gt;
&lt;p&gt;Появляется отдельный промежуточный слой: Kotlin-код проходит через compiler, Gradle, JS/Wasm-сборку и только потом попадает в браузер. Когда что-то ломается, не всегда сразу понятно, где именно треснуло: в Kotlin DSL, в interop, в generated JavaScript, в source maps, в webpack-обвязке или уже в самом browser API.&lt;/p&gt;
&lt;p&gt;Это не катастрофа, но дебажить иногда менее прозрачно, чем обычный TypeScript-код, который вы сами же и написали.&lt;/p&gt;
&lt;p&gt;CI/CD тоже не становится стерильным JVM-аквариумом. Kotlin/JS тянет npm-зависимости, package lock, webpack/Vite-историю и все сопутствующие радости. Они лучше спрятаны, но в момент поломки все равно выйдут на сцену.&lt;/p&gt;
&lt;/div&gt;
&lt;/details&gt;
&lt;p&gt;Ну и молодость фреймворка чувствуется. Для pet project, внутренней админки или эксперимента — отлично. Для критичного
интерфейса с большой командой я бы пока десять раз подумал. Возможно, даже одиннадцать, если рядом есть frontend-лид
с битой.&lt;/p&gt;
&lt;h2&gt;Вывод&lt;span class="hx:absolute hx:-mt-20" id="вывод"&gt;&lt;/span&gt;
&lt;a href="#%d0%b2%d1%8b%d0%b2%d0%be%d0%b4" class="subheading-anchor" aria-label="Ссылка на этот раздел"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;div class="hx:overflow-x-auto hx:mt-6 hx:flex hx:rounded-lg hx:border hx:py-2 hx:ltr:pr-4 hx:rtl:pl-4 hx:contrast-more:border-current hx:contrast-more:dark:border-current hx:border-purple-200 hx:bg-purple-100 hx:text-purple-900 hx:dark:border-purple-200/30 hx:dark:bg-purple-900/30 hx:dark:text-purple-200"&gt;
&lt;div class="hx:ltr:pl-3 hx:ltr:pr-2 hx:rtl:pr-3 hx:rtl:pl-2"&gt;&lt;svg height=1.2em class="hx:inline-block hx:align-middle" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="hx:w-full hx:min-w-0 hx:leading-7"&gt;
&lt;div class="hx:mt-6 hx:leading-7 hx:first:mt-0"&gt;Kilua выглядит особенно уместно для Kotlin-команд, внутренних инструментов, pet projects и fullstack-экспериментов. Для большого публичного frontend с уже сложившейся JS-экосистемой его стоит выбирать очень осознанно.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Попробовать Kilua точно стоит. Хотя бы ради того момента, когда ты пишешь &lt;code&gt;@Composable&lt;/code&gt; в браузерном приложении,
собираешь это Gradle&amp;rsquo;ом, открываешь DevTools и видишь обычный DOM. В этот момент frontend на Kotlin перестает быть
абстрактной идеей из презентации и становится вполне конкретным кодом. Немного странным, но живым.&lt;/p&gt;
&lt;p&gt;Если на проекте уже используется KVision, то переход в Kilua будет логичным шагом.&lt;/p&gt;</description></item></channel></rss>