Author SHA1 Message Date
Hermes 5987f71032 fix(portfolio): mp-card--reflect — use --surface (was undefined --card) for tint, fix pseudo-element stacking; regenerate broken placeholder 2026-09-17 14:26:03 +02:00
Hermes fa4a3d91aa feat(portfolio): add mp-card--reflect variant (full-bleed image, mirror, progressive blur+tint), apply to Portfolio block + specimen 2026-09-16 17:36:54 +02:00
Hermes 49ea1238eb fix(portfolio): mp-card--bleed — round only image top-left; reset media inherit radius 2026-09-16 16:27:09 +02:00
Hermes da6e752776 style(portfolio): mp-card--bleed only top-left radius; hover scales media container 2026-09-16 16:24:12 +02:00
Hermes f9cea9f097 style(portfolio): mp-card--bleed image — top-left radius, subtle border, hover zoom from bottom-right 2026-09-16 16:20:55 +02:00
Hermes f550f70318 feat(portfolio): add mp-card--bleed variant, use in Portfolio block + design-system 2026-09-16 16:14:07 +02:00
Hermes 336f9b5707 feat(portfolio): add portfolio-card--bleed variant, use in Portfolio block + design-system
- new .portfolio-card--bleed: image pinned bottom-right, no padding,
  corner rounded by card (overflow:hidden)
- design-system catalog: add bleed specimen (ru/en)
- projects-section (ru/en): portfolio cards now use --bleed
2026-09-16 15:25:53 +02:00
root 97ea48bc6f feat: per-theme accent + Activity WhatsApp(en)/GitHub cards, softer icon borders
- theme: store accent separately for light/dark (theme-core/controls)
- en contacts: WhatsApp card instead of VK (wa.me link)
- Activity: add GitHub card (logo webp) in both locales
- mp-card__image border uses --line to match card (less contrast)
2026-09-16 14:46:08 +02:00
root 302855efcf style(contacts): stretch contact cards to full width (auto-fit grid)
- replace fixed 2/4-column contact-grid with repeat(auto-fit, minmax(0,1fr))
  so cards fill the row like mp-card (Activity) cards; stack on mobile
2026-09-16 12:57:33 +02:00
root fe9248c212 fix(contacts): remove Yandex card from contacts block
- drop contact-card--brand-ya (yandex chat) from contact-section ru/en
- update cookie-policy contacts enumeration to drop Yandex (ru/en)
- rebuild public/
2026-09-16 12:50:10 +02:00
root 4b52cec00d feat(i18n): translate all case studies and resume to English
- add .en.html overrides for 16 case pages + resume via apinex translator
- translate remaining partials (projects-related, mini-projects-*, fab-stack-home-contact,
  fab-stack-resume, content-edit-fab-stack, cookie-notice)
- enhance translate.mjs to protect <script>/<style> from translation
- second pass: translate attribute values (aria-label/alt/title/placeholder/content/
  data-hint-default) and visible text DeepL skipped in html mode
- make logo language-aware (NM / НМ) via i18n
2026-09-15 23:18:01 +02:00
root 20ee81bfaf feat(i18n): move language switcher into footer, restyle laconic
- remove {{> lang-switch}} from header-home/case/minimal
- add {{> lang-switch}} to footer
- restyle .lang-switch as compact text group 'RU / EN' (active = accent),
  drop pill borders/background for a minimal look
2026-09-15 23:02:53 +02:00
root e02ce1c629 fix(i18n): keep en navigation within /en/ via pagePrefix
- build-pages.mjs: add pagePrefix (locale-root-relative, no en offset)
- use pagePrefix for nav links: logo, back-home, footer design-system/
  cookie-policy/, FAB #contact, cookie-notice link, cookie-policy body
  link, case-page #skills/#projects anchors (assetPrefix pointed to site
  root and leaked en pages to ru)
- docs/translation.md: document assetPrefix vs pagePrefix
2026-09-15 23:00:32 +02:00
root 1d25dcee47 docs: document i18n localization and apinex translation workflow
- docs/translation.md: build-time ru/en architecture, build-pages mechanics,
  apinex translator API + scripts/translate.mjs usage, page translation
  workflow, adding a new language
- AGENTS.md: link docs/translation.md in key-files table
2026-09-15 22:51:37 +02:00
root 86ea6731fa feat: apinex translator CLI (scripts/translate.mjs) + npm translate script
- scripts/translate.mjs: wraps apinex.bond /v1/tools/translator (free)
- protects {{tokens}} and <code> from translation in HTML mode
- supports --text/--file/--batch/--list-languages, auto HTML detection
- npm run translate alias
2026-09-15 22:35:33 +02:00
root a0dda98b83 i18n: build-time ru/en localization + EN homepage & cookie-policy
- build-pages.mjs: per-locale page/partial overrides (*.en.html), i18n token
  resolution with ru fallback, <html lang> + assetPrefix per locale
- UI dictionaries src/i18n/{ru,en}.json; lang-switch partial
- headers/footer/partials converted to {{i18n:…}} tokens
- root-absolute static asset refs so /en/ subpath loads css/js/vendor
- EN content: homepage (index.en.html + partials) and cookie-policy.en.html
  (cookie-policy body translated via apinex.bond /v1/tools/translator)
2026-09-15 22:27:51 +02:00
root a78d431d3c ui: центр + отступ после табов case-media-slider__anim-switch 2026-09-11 14:50:24 +02:00
root bd20a4040d fix: цвет иконки FAB icon-only на design-system (исключён .fab из правила ссылок) 2026-09-11 14:49:40 +02:00
root f7545e4203 feat: появление секции marquee при скролле (data-reveal, мягкий fade+сдвиг) 2026-09-11 13:47:19 +02:00
root 4212579c5a fix: внутренние ссылки без .html (clean URL) в партиалах, cases, cookie-policy, resume 2026-09-11 13:43:53 +02:00
root 5b1725e330 style: --edge-pad уменьшен вдвое (clamp(0.5rem,2vw,1.4rem)) 2026-09-11 13:38:18 +02:00
root 400f2d3cfa style: header скрыт вверху, плавающая панель только при скролле 2026-09-11 13:20:35 +02:00
root e0bfe5a747 style: header__inner единый отступ (--edge-pad), убран боковой max() в padding 2026-09-11 13:17:28 +02:00
root 73fb0a1636 style: единый отступ fixed FAB и плавающего меню (--edge-pad) 2026-09-11 13:06:54 +02:00
root 8f40fcb5ed feat: дефолт fade для карусели; просмотрщик без анимации смены кадра 2026-09-11 13:03:46 +02:00
root a43aaa5e22 feat: case-media-gallery--wide — 4 колонки, выход за колонку как у case-media--wide 2026-09-10 21:35:49 +02:00
root bd9b749a10 style: hover кнопок плеера — иконка в акцент; фон кнопки Play как у панели 2026-09-10 20:57:20 +02:00
root 2d0f3f3989 feat: варианты анимации слайдера (slide/fade/none), пилюли-точки, blur-фон просмотрщика, фикс открытия в natural-слайдерах 2026-09-10 19:58:21 +02:00
root c14bb1f543 fix: доработки слайдера и просмотрщика
- case-media-slider: обрамление+hover как у карточек, стрелки только при наведении (fade-in),
  группа пред/след-пилюля в правом нижнем углу, disabled-кнопки видны (не скрываются),
  убран border-radius у img/figcaption (скругление у viewport), неактивные слайды не скрываются
  visibility (нет 'дырки' при перелистывании), настраиваемая ширина --wide/--content/--natural
- image-load-placeholder: исключён .case-media-slider (нет блика/серого фона)
- icon-press: универсальный эффект 'продавливания' иконки при нажатии (.icon-press + :active/.is-pressed),
  применён к слайдеру и всем кнопкам просмотрщика
2026-09-09 19:29:30 +02:00
root 95b4eaca93 feat: галерея-слайдер, листалка в просмотрщике и TUI braille-спиннер
- case-media-slider: переиспользуемый слайдер (prev/next + точки, swipe, клавиатура, lazy)
- image-viewer: листалка prev/next в тулбаре как пилюли, пагинация-точки в тулбаре слева,
  группа масштаба (slider + текст) с gap 0.25rem, текст масштаба --text-muted/400
- tui-spinner: переиспользуемый braille-спиннер (чистый CSS), лоадер просмотрщика
- design-system.html: живой пример TUI braille-спиннера
- fix: удалён мёртвый textContent='Загрузка...', убивавший .tui-spinner при повторном открытии
2026-09-09 17:49:56 +02:00
root 9b91ed165f fix: карточки «Смотрите также» встают в ряд на ширине ~1278px
data-mp-cells-sm=2 для карточек related — в диапазоне 4 колонок
(≈1080–1355px вьюпорта) карточки занимают по 2 колонки и парятся,
устраняя пустую колонку справа. В десктопном 6-колоночном диапазоне
сохраняется data-mp-cells=3.
2026-09-08 19:04:42 +02:00
root 16dafcbf01 chore: удалена пустая секция section--surface после контактов
Убран инклюд {{> footer-spacer}} (пустая <section class="section section--surface"
aria-hidden> как прокладка перед футером) на главной и во всех 16 кейсах.
Пересобраны страницы (build:pages).
2026-09-08 18:15:04 +02:00
170 changed files with 44838 additions and 12236 deletions

No files matched your search

+1
View File
@@ -80,3 +80,4 @@
| `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) |
| `docs/case-page-blocks.md` | Инструкция по блокам лонгридов |
| `docs/resume-pdf.md` | Генерация PDF резюме (Puppeteer) |
| `docs/translation.md` | Локализация (ru/en) и перевод через apinex |
+2
View File
@@ -172,6 +172,8 @@
| `.case-media--natural` | Изображение в натуральном масштабе |
| `.case-media--content` | По ширине текстовой колонки |
| `.case-media--wide` | Шире колонки статьи |
| `.case-media-gallery--wide` | Шире колонки статьи (галерея) |
| `.case-media-slider` | Слайдер: одна картинка, стрелки и точки-пагинация |
---
+51 -1
View File
@@ -141,6 +141,56 @@
**Сетка + отложенный `src`:** в ячейках можно задать реальный URL в **`data-src`** и плейсхолдер в `src` (крошечный `data:image/gif;base64,…`), чтобы [`initCaseSequentialImages`](public/js/modules/cases/sequential-images.js) подставлял картинки **по порядку в DOM** (в т.ч. строка за строкой в галерее). Пока у `img` есть `data-src`, просмотр по клику не открывается. Пример: [`src/pages/cases/lunacy.html`](src/pages/cases/lunacy.html).
### Слайдер изображений с точками (`case-media-slider`)
Одна картинка во весь блок, навигация стрелками «пред/след» и пагинация точками (активная — акцентным цветом). Переиспользует маски стрелок `shevron-left/right.svg` и токены темы. Логика: [`public/js/modules/cases/media-slider.js`](public/js/modules/cases/media-slider.js) (`initCaseMediaSlider()` вызывается из [`public/js/main.js`](public/js/main.js)).
**Возможности:**
- стрелки `data-slider-prev` / `data-slider-next` — переключают соседний слайд, блокируются (`disabled`) на краях;
- точки `data-slider-dot` (в `data-slider-dots`) — переход на конкретный слайд, активная получает `is-active` + `aria-selected`;
- клавиатура: ←/→ внутри блока, Home/End на точках;
- тач/мышь-свайп (только горизонтальный);
- ленивая подгрузка соседних кадров: у `<img>` можно задать `data-src` (временный `src` — прозрачный GIF), скрипт подставит при подходе, как у галереи; пока есть `data-src`, клик по viewer не открывается;
- клик по активному кадру открывает общий viewer кейса (`data-image-viewer`).
**Ширина слайдера подстраивается под ширину картинок** (высота — автоматически по контенту). Модификаторы как у статичных `.case-media`:
- базовый (без модификатора) / `.case-media--content` — ширина = `--slider-natural-w` (по умолчанию 100% колонки контента). Задайте на `<figure>` inline-стиль `--slider-natural-w: <ширина картинки>px`, чтобы слайдер точно встал в размер кадров и не превышал его; `max-width` ограничен вьюпортом, поэтому на узких экранах сжимается;
- `.case-media--wide` — слайдер выходит за колонку контента (как одиночные wide-картинки), центрируется по вьюпорту;
- `.case-media--natural` — как базовый, но `max-width` по умолчанию `600px` (переопределите `--slider-natural-w`).
Кнопки «пред/след» и точки находятся **внутри** `.case-media-slider__viewport`, поэтому не участвуют в расчёте ширины блока.
**Разметка** (число `data-slider-dot` должно равняться числу `.case-media-slider__slide`; `aria-label` точек — «Перейти к изображению N»):
```html
<figure class="case-media case-media-slider" data-case-media-slider>
<div class="case-media-slider__viewport" data-slider-viewport>
<div class="case-media-slider__track" data-slider-track>
<div class="case-media-slider__slide">
<img src="../assets/<папка>/<файл-1>.webp" alt="…" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="../assets/<папка>/<файл-2>.webp" alt="…" loading="lazy" decoding="async" data-image-viewer />
</div>
</div>
</div>
<button type="button" class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--right" aria-hidden="true"></span>
</button>
<div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 1"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 2"></button>
</div>
</figure>
```
Живой пример: [`src/pages/cases/template-elements-demo.html`](src/pages/cases/template-elements-demo.html) (секция «Слайдер»).
### Последовательная подгрузка (`data-case-sequential-images`)
- Оберни фрагмент статьи с картинками (часто весь `.case-article`): **`data-case-sequential-images`** на контейнере.
@@ -183,7 +233,7 @@ PNG/JPEG → WebP по папке: `docs/image-optimization.md`, скрипт `s
Подключаемые через `{{> head-links}}` сценарии уже инициализируют:
- последовательную подгрузку `data-src` внутри `[data-case-sequential-images]` (`initCaseSequentialImages`);
- просмотр картинок по клику (`data-image-viewer`, делегирование на документ);
- просмотр картинок по клику (`data-image-viewer`, делегирование на документ); при открытии из **галереи** (`.case-media-gallery`) или **слайдера** (`.case-media-slider`) в нижней панели просмотрщика появляются **кнопки-пилюли «пред/след»** и **точки-пагинация** — переключают только картинки этого блока (клавиши ←/→, свайп по горизонтали, клик по точке); у одиночной картинки кнопки и точки скрыты;
- плеер видео кейса (`data-case-video-player`);
- натуральный размер для `.case-media--natural img`;
- сворачивание длинного скрина (`[data-case-tall-media]`, `initCaseTallMediaExpand`).
+104
View File
@@ -0,0 +1,104 @@
# Локализация и перевод
Сайт локализован **build-time**: для каждого языка собирается отдельная папка/URL (`/` — русский, `/en/` — английский). Клиентского переключателя нет — это сделано ради SEO, корректной генерации PDF (Puppeteer) и совместимости с content-edit-mode.
## Архитектура
- `LOCALES = ["ru", "en"]`, `DEFAULT_LOCALE = "ru"` — задаётся в `scripts/build-pages.mjs`.
- Дефолтная локаль собирается в корень `public/`, остальные — в `public/<lang>/` (`public/en/`).
- Переключатель языка: `src/partials/lang-switch.html` (показывает RU/EN, `aria-current` на активном).
## Как работает сборка (`build-pages.mjs`)
1. **UI-строки** (навигация, футер, кнопки) вынесены в словари `src/i18n/{ru,en}.json` и вставляются токеном `{{i18n:key}}`. Для не-дефолтной локали значение берётся из `en.json`, при отсутствии ключа — фоллбэк на `ru.json`. Неразрезолвенный токен остаётся как `{{i18n:key}}` (признак пропущенного ключа).
2. **Тело страницы** (главная, cookie-policy, кейсы) живёт в per-locale исходниках:
- `src/pages/<page>.html` — русский (дефолт).
- `src/pages/<page>.en.html` — английский override. Если файла нет, берётся русский.
- В сборке базовый walk **исключает** `*.en.html`, чтобы они не собирались как отдельные страницы.
3. **Partials**: для не-дефолтной локали инклуд `{{> name}}` сначала ищет `src/partials/name.en.html`, иначе `name.html`. Так переводятся частично захардкоженные partials (например, `projects-section.en.html`).
4. **`assetPrefix`**: относительный префикс к ассетам. Для `en` к значению из `baseVars` добавляется один уровень `../`, потому что страницы лежат в подпапке `public/en/`. Используй только для путей к ассетам (css/js/изображения).
5. **`pagePrefix`**: префикс до **корня локали** (`/en/` для en), без en-сдвига. Используй его для ВСЕХ навигационных ссылок (лого, «назад», футер, якоря `#section`, ссылки на другие страницы), чтобы они вели в ту же локаль, а не в корень сайта. `assetPrefix` предназначен только для путей к ассетам — для навигации он «перепрыгивает» через `/en/` и ведёт на ru, поэтому в навигационных `href` его НЕ применяй.
6. **Статик-ассеты** (css/js/vendor/favicon) в partials (`head-links.html`, `head-scripts.html`, `theme-init-script.html`, `head-plyr.html`, `page-scripts.html`) прописаны **root-absolute** (`/css/...`, `/js/...`, `/vendor/...`, `/assets/shared/...`). Причина: dev-сервер (`serve-dev.mjs`) отдаёт `index.html` (200) для отсутствующих файлов, маскируя 404, поэтому относительные `{{assetPrefix}}css` на `/en/` ломались бы. Держи статик-ссылки root-absolute.
7. `<html lang="ru">` подменяется на текущую локаль (`<html lang="en">`).
> Видимый контент страниц переводится через отдельные `*.en.html` исходники, а НЕ тысячами `{{i18n:}}`-токенов — так проще править большие тексты.
## Перевод через apinex
Бесплатный инструмент перевода: `POST https://api.apinex.bond/v1/tools/translator` (дока: `https://apinex.bond/developers/tools/translator`).
- **Ключ** — в `/root/.hermes/.env`, переменная `HERMES_CUSTOM_APINEX_API_KEY` (или `APINEX_API_KEY`). **Никогда не выводи значение ключа в чат/логи.**
- Заголовок: `Authorization: Bearer $KEY`.
- Тело: `{ "text": "строка|массив", "target_lang": "EN", "source_lang": "RU", "tag_handling": "html", "preserve_formatting": true }`. Батч до 50 строк / 100k символов, лимит 60 запросов/мин.
### Скрипт `scripts/translate.mjs`
Обёртка над API (Node, без зависимостей). Автоматически **защищает** от перевода шаблонные токены `{{assetPrefix}}`, `{{> partial}}`, `{{i18n:…}}` и содержимое `<code>` (через HTML-комментарии, восстанавливаются после). HTML-режим авто-включается, если во входе есть `<…>`.
```bash
cd /root/.hermes/projects/site-portfolio
# ключ в окружение (без печати значения)
export HERMES_CUSTOM_APINEX_API_KEY="$(grep -iE 'HERMES_CUSTOM_APINEX_API_KEY' /root/.hermes/.env | head -1 | cut -d= -f2- | sed 's/^["'\''"]//;s/["'\''"]$//')"
# список поддерживаемых языков
node scripts/translate.mjs --list-languages
# одна строка
node scripts/translate.mjs --target EN --source RU --text "Политика cookie"
# HTML из stdin (токены и <code> защищены)
echo '<p>{{assetPrefix}} <code>siteThemeV1</code></p>' | node scripts/translate.mjs --target EN --html
# файл страницы -> per-locale override
node scripts/translate.mjs --target EN --source RU --file src/pages/foo.html --out src/pages/foo.en.html
# пакет: одна строка на line
printf 'строка1\nстрока2\n' | node scripts/translate.mjs --target EN --batch
# npm-алиас
npm run translate -- --target EN --text "..."
```
- `--target` обязателен; `--source` опционален (авто-детект).
- `--html`/`--no-html` — принудительно вкл/выкл HTML-режим (иначе авто).
- `--out` пишет в файл, иначе вывод в stdout.
### Скилл `translate-apinex`
Загружается агентом при запросе перевода: содержит API, где ключ, защиту токенов и воркфлоу. Используй его вместо ручного копирования curl.
## Воркфлоу: перевести страницу
1. Создать `src/pages/<page>.en.html` (копия `<page>.html`).
2. Прогнать через скрипт:
```bash
node scripts/translate.mjs --target EN --source RU --file src/pages/<page>.en.html --out src/pages/<page>.en.html
```
Скрипт переведёт видимый текст, оставив `{{…}}` и `<code>` нетронутыми.
3. Если страница инклудит partials с захардкоженным русским (`projects-section`, `contact-section`, `cookie-notice`, `fab-stack`, `mini-projects-section`), создать их `.en.html` и перевести так же — сборка подхватит `.en`-вариант для `en`.
4. UI-строки, оставшиеся как `{{i18n:key}}`, добавить в `src/i18n/en.json`.
5. `npm run build:pages`.
6. Проверить:
```bash
curl -s http://localhost:4175/en/<page>/ | grep -o '<html lang="en">'
curl -s http://localhost:4175/en/<page>/ | grep -cE 'остался русский текст' # ожидаем 0
```
## Добавить новый язык
1. Добавить код в массив `LOCALES` в `scripts/build-pages.mjs`.
2. Создать `src/i18n/<code>.json` (UI-строки).
3. Создавать `<page>.<code>.html` / `<name>.<code>.html` override для нужных страниц/партилов (или полагаться на фоллбэк ru).
4. Пересобрать. URL новой локали — `/<code>/`.
## Где что лежит
| Файл | Назначение |
|---|---|
| `scripts/build-pages.mjs` | Сборка локалей, резолв `{{i18n:}}`, override страниц/партилов, `assetPrefix`, `lang` |
| `src/i18n/ru.json`, `src/i18n/en.json` | Словари UI-строк |
| `src/pages/<page>.en.html` | Переведённое тело страницы (override) |
| `src/partials/<name>.en.html` | Переведённый partial (override) |
| `src/partials/lang-switch.html` | Переключатель RU/EN |
| `scripts/translate.mjs` | CLI-обёртка над apinex translator |
| `.env` (`/root/.hermes/.env`) | `HERMES_CUSTOM_APINEX_API_KEY` — ключ apinex (не коммитить) |
+1
View File
@@ -6,6 +6,7 @@
"build:pages": "node scripts/build-pages.mjs",
"build": "npm run vendor:copy && npm run build:pages",
"serve": "node scripts/serve-dev.mjs",
"translate": "node scripts/translate.mjs",
"convert-videos": "node scripts/convert-videos.mjs",
"optimize-images": "node scripts/optimize-images.mjs",
"generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica"
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>bake paper texture</title></head>
<body style="margin:0">
<div id="host" style="width:512px;height:512px"></div>
<script type="module">
import { ShaderMount, paperTextureFragmentShader, getShaderNoiseTexture } from "/_lab/paper-bundle.js";
// Параметры из пресета Figma (по умолчанию)
const P = window.__PARAMS || {
u_colorBack: [1, 1, 1, 1],
u_colorFront: [0, 0, 0, 1],
u_contrast: 0.3,
u_roughness: 0.4,
u_fiber: 0.3,
u_fiberSize: 0.2,
u_crumples: 0.3,
u_crumpleSize: 0.35,
u_folds: 0.65,
u_foldCount: 5,
u_drops: 0.2,
u_fade: 0,
u_seed: 5.8,
u_scale: 0.6,
};
try {
const host = document.getElementById("host");
const noise = getShaderNoiseTexture();
await noise.decode();
const mount = new ShaderMount(
host,
paperTextureFragmentShader,
{ ...P, u_noiseTexture: noise },
// preserveDrawingBuffer обязателен: без него буфер очищается
// после композитинга и readPixels вернёт пустоту
{ antialias: false, preserveDrawingBuffer: true },
0,
0,
1,
);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = host.querySelector("canvas");
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
const w = canvas.width, h = canvas.height;
const px = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
let min = 255, max = 0, sum = 0, n = 0;
for (let i = 0; i < px.length; i += 4) {
const v = px[i];
if (v < min) min = v;
if (v > max) max = v;
sum += v; n++;
}
const avg = sum / n;
let vs = 0;
for (let i = 0; i < px.length; i += 4) vs += (px[i] - avg) ** 2;
// readPixels читает снизу вверх — переворачиваем
const out = document.createElement("canvas");
out.width = w; out.height = h;
const ctx = out.getContext("2d");
const img = ctx.createImageData(w, h);
for (let y = 0; y < h; y++) {
img.data.set(px.subarray((h - 1 - y) * w * 4, (h - y) * w * 4), y * w * 4);
}
ctx.putImageData(img, 0, 0);
window.__STATS = { w, h, min, max, avg: +avg.toFixed(2), sd: +Math.sqrt(vs / n).toFixed(2) };
window.__URL = out.toDataURL("image/png");
window.__DONE = true;
} catch (e) {
window.__ERR = e.message + " | " + e.stack;
}
</script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="geologica">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Песочница _lab</title>
<link rel="stylesheet" href="/css/main.css" />
<link rel="stylesheet" href="/_lab/lab.css" />
<style>
.lab-index {
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 6vw, 4rem) var(--space) 6rem;
}
.lab-index__h1 {
font-family: var(--font-accent);
font-size: clamp(1.8rem, 4vw, 2.5rem);
margin: 0 0 0.5rem;
}
.lab-index__sub {
color: var(--text-muted);
line-height: 1.6;
margin: 0 0 2.5rem;
max-width: 40rem;
}
.lab-index__list {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.lab-index__card {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--radius-md);
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
text-decoration: none;
box-shadow: 2px 2px 4px hsla(0, 0%, 0%, 0.1), 4px 8px 8px hsla(0, 0%, 0%, 0.15);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.lab-index__card:hover {
transform: translateY(-2px);
box-shadow: 2px 2px 4px hsla(0, 0%, 0%, 0.1), 4px 8px 12px hsla(0, 0%, 0%, 0.22);
}
.lab-index__title {
font-family: var(--font-accent);
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 0.35rem;
}
.lab-index__desc {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
}
.lab-index__note {
margin-top: 2.5rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid var(--line);
font-size: 0.85rem;
line-height: 1.6;
color: var(--text-muted);
}
.lab-index__note code {
font-family: var(--font);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
</style>
</head>
<body>
<div class="page-mesh-bg" aria-hidden="true"></div>
<main class="lab-index">
<h1 class="lab-index__h1">Песочница _lab</h1>
<p class="lab-index__sub">
Стенды для настройки видa элементов без сборки сайта. Правишь
<code>public/css/main.css</code> или файл стенда — страница перезагружается сама (live-reload
сервера <code>npm run serve</code>). Когда вид готов, переносишь компонент в
<code>src/pages/**</code> и собираешь <code>npm run build:pages</code>.
</p>
<div class="lab-index__list">
<a class="lab-index__card" href="/_lab/neu-card.html">
<div class="lab-index__title">neu-card</div>
<div class="lab-index__desc">
Неуморфная карточка: токены, состояния, текстура, вложенность.
</div>
</a>
<a class="lab-index__card" href="/_lab/template.html">
<div class="lab-index__title">template</div>
<div class="lab-index__desc">Пустой шаблон для нового стенда.</div>
</a>
</div>
<div class="lab-index__note">
<strong>Как добавить стенд:</strong> скопируй <code>/_lab/template.html</code> в
<code>/_lab/&lt;имя&gt;.html</code>, поправь заголовок и секции. Стили компонента — в
<code>public/css/main.css</code>, обвязка стенда — в <code>/_lab/lab.css</code>.
Переключатель темы подключается автоматически через <code>lab.js</code>.
</div>
</main>
</body>
</html>
+404
View File
@@ -0,0 +1,404 @@
/* Обвязка стенда. Сюда не пишем стили компонента — только рамки, сетки,
подписи и подложки. Стили neu-card живут в public/css/main.css. */
.lab {
max-width: 62rem;
margin: 0 auto;
padding: clamp(2rem, 5vw, 3.5rem) var(--space) 8rem;
}
.lab__head {
margin-bottom: clamp(2.5rem, 6vw, 4rem);
}
.lab__h1 {
margin: 0 0 0.5rem;
font-family: var(--font-accent);
font-size: clamp(1.8rem, 4vw, 2.5rem);
font-weight: 700;
line-height: 1.1;
}
.lab__sub {
margin: 0 0 1.25rem;
max-width: 42rem;
color: var(--text-muted);
line-height: 1.6;
}
.lab__nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.lab__nav a {
color: var(--accent-text);
text-decoration: none;
border-bottom: 1px solid transparent;
}
.lab__nav a:hover {
border-bottom-color: currentColor;
}
.lab__sec {
padding-top: clamp(2rem, 5vw, 3rem);
margin-bottom: clamp(2rem, 5vw, 3rem);
scroll-margin-top: 1.5rem;
}
.lab__h2 {
margin: 0 0 0.5rem;
font-family: var(--font-accent);
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
font-weight: 700;
line-height: 1.2;
}
.lab__h3 {
margin: 2.5rem 0 0.5rem;
font-family: var(--font-accent);
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
color: var(--text);
}
.lab__note {
margin: 0 0 1.5rem;
max-width: 46rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-muted);
}
.lab__caption {
margin: 0.75rem 0 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--text-muted);
}
.lab code {
font-family: var(--font);
font-size: 0.92em;
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.lab__row {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-start;
}
.lab__row--wrap .neu-card {
flex: 0 0 auto;
}
/* ── Токены ─────────────────────────────────────────────────────── */
.lab__tokens {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
}
.lab__token {
margin: 0;
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
background: var(--bg-elevated);
border: 1px solid var(--line);
}
.lab__token dt {
font-size: 0.82rem;
margin-bottom: 0.3rem;
}
.lab__token dt span {
color: var(--text-muted);
margin-left: 0.35rem;
}
.lab__token dd {
margin: 0;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-muted);
}
/* ── Фоны ───────────────────────────────────────────────────────── */
.lab__bg-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
}
.lab__bg-cell {
padding: 1.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--line);
}
.lab__bg-cell[data-bg="page"] {
background: var(--bg);
}
.lab__bg-cell[data-bg="elevated"] {
background: var(--bg-elevated);
}
.lab__bg-cell[data-bg="surface"] {
background: var(--surface);
}
.lab__bg-cell[data-bg="accent"] {
background: var(--accent);
color: var(--on-accent, var(--text));
}
.lab__bg-cell[data-bg="accent"] .lab__caption {
color: color-mix(in srgb, var(--on-accent, var(--text)) 78%, transparent);
}
.lab__bg-cell[data-bg="media"] {
background-image: url("/assets/template-elements-demo/img-long.webp");
background-size: cover;
background-position: center;
color: #fff;
}
.lab__bg-cell[data-bg="media"] .lab__caption {
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.lab__bg-cell[data-bg="media"] .neu-card {
background: var(--surface);
color: var(--text);
}
/* ── Состояния ──────────────────────────────────────────────────── */
.lab__state {
display: flex;
flex-direction: column;
}
.lab__depth {
gap: 1.25rem;
}
.lab__depth .neu-card {
min-width: 150px;
}
/* ── Сетка ──────────────────────────────────────────────────────── */
.lab__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.lab__grid--3 {
grid-template-columns: repeat(3, 1fr);
}
.lab__grid--tight {
gap: 0.5rem;
}
@media (max-width: 640px) {
.lab__grid--3 {
grid-template-columns: 1fr;
}
}
/* ── Переключатель стенда ───────────────────────────────────────── */
.lab-switch {
position: fixed;
right: max(1rem, env(safe-area-inset-right, 0px));
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
z-index: 200;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 8px 24px hsla(0, 0%, 0%, 0.25);
font-size: 0.78rem;
}
.lab-switch__btn {
appearance: none;
border: 0;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
font: inherit;
font-family: var(--font-accent);
cursor: pointer;
}
.lab-switch__btn:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.lab-switch__btn[aria-pressed="true"] {
background: var(--accent);
color: var(--on-accent, #fff);
}
.lab-switch__sep {
width: 1px;
height: 1.25rem;
margin: 0 0.15rem;
background: var(--line);
}
/* ── Карточка в карточке ────────────────────────────────────────── */
.lab__nest {
margin-bottom: 1.5rem;
}
/* Внешняя карточка: увеличенные поля и минимум, чтобы вместить вложенные */
.neu-card--nest {
--neu-pad: 1.75rem;
--neu-min-w: 0;
--neu-min-h: 0;
gap: 1rem;
justify-content: flex-start;
}
.neu-card__nest-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.neu-card__nest-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
@media (max-width: 640px) {
.neu-card__nest-grid {
grid-template-columns: 1fr;
}
}
/* ── Шейдер-карточка (прототип Paper Shaders) ───────────────────── */
/* canvas шейдера — фоновый слой ВНУТРИ карточки, под контентом.
pointer-events:none — чтобы клик/hover доходили до самой карточки. */
.contact-card--shader {
overflow: hidden;
isolation: isolate;
}
.card-shader {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
/* нейтральный фон-запас: если WebGL не отрисовал, карточка выглядит как
обычная neu-card (--neu-bg). Когда шейдер рисует — он перекрывает этот фон. */
background: var(--neu-bg);
}
/* ── neu-card + живой шейдер-слой под бликом ───────────────────────────────
Обычная неуморфная карточка (тени снаружи + блик) с анимированным шейдером
ВНУТРИ, под бликом. Слои (снизу вверх):
0 — .card-shader (canvas шейдера)
1 — ::before (блик --neu-glow) И ::after (внутренний объём inset-теней)
2 — контент карточки
Важно: box-shadow родителя рисуется НИЖЕ дочерних слоёв, поэтому шейдер (z:0)
перекрыл бы внутренние тени. Чтобы объём был виден ПОВЕРХ шейдера, внешние
тени (drop) оставляем на карточке, а внутренний объём (inset) выносим на
::after с z-index:1 — он рисуется поверх шейдера. */
.neu-card--shader {
background: transparent; /* фон уходит, шейдер снизу */
isolation: isolate;
/* только внешний объём (приподнятость) */
box-shadow: var(--neu-drop-1), var(--neu-drop-2), var(--neu-drop-3);
}
.neu-card--shader::before {
content: "";
position: absolute;
inset: 0;
z-index: 1; /* поверх шейдера (z:0) */
border-radius: inherit;
pointer-events: none;
background: var(--neu-glow); /* блик из левого верхнего угла */
}
.neu-card--shader::after {
content: "";
position: absolute;
inset: 0;
z-index: 1; /* поверх шейдера, рядом с бликом */
border-radius: inherit;
pointer-events: none;
/* внутренний объём (выпуклость): белая кайма сверху-слева + тёмная снизу-справа */
box-shadow:
var(--neu-in-light-1), var(--neu-in-light-2),
var(--neu-in-dark-1), var(--neu-in-dark-2);
}
.neu-card--shader > *:not(.card-shader) {
position: relative;
z-index: 2; /* контент над бликом/объёмом и шейдером */
}
.contact-card--shader .contact-card__icon,
.contact-card--shader .contact-card__body,
.contact-card--shader .contact-card__corner {
position: relative;
z-index: 1;
}
/* отдельная демо-карточка шейдера */
.shader-demo-card {
position: relative;
overflow: hidden;
isolation: isolate;
width: 100%;
max-width: 360px;
min-height: 220px;
border-radius: var(--radius-card);
background: var(--neu-bg);
box-shadow: var(--neu-shadow);
}
.shader-demo-card--wide {
max-width: 420px;
min-height: 260px;
}
.shader-demo-card__label {
position: absolute;
left: 1rem;
bottom: 0.85rem;
z-index: 1;
font-family: var(--font-accent);
font-size: 0.95rem;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
pointer-events: none;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Стенд: переключение темы и скругления.
* Пишет в localStorage под тем же ключом, что и панель темы сайта,
* поэтому выбранный режим сохранится при переходе на сам сайт.
*/
import { STORAGE_KEY, readThemeState, writeThemeState } from "/js/modules/theme/theme-core.js";
import { ensureThemeFontLoaded } from "/js/modules/theme/theme-fonts.js";
function apply(patch) {
const state = { ...readThemeState(), ...patch };
writeThemeState(state);
const root = document.documentElement;
// Тема: только светлая/тёмная. Режим «system» на стенде не нужен —
// важно видеть обе темы принудительно.
if (state.mode === "system") state.mode = prefersDark() ? "dark" : "light";
root.dataset.theme = state.mode;
if (state.radius) root.dataset.radius = state.radius;
if (state.font) root.dataset.font = state.font;
const labAcc = state[state.mode] || state.light || { h: 210, s: 90, l: 40 };
root.style.setProperty("--theme-h", String(labAcc.h));
root.style.setProperty("--theme-s", String(labAcc.s));
root.style.setProperty("--theme-l", String(labAcc.l));
ensureThemeFontLoaded(state.font);
syncButtons();
}
function prefersDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function bind(attr, key) {
const btns = document.querySelectorAll(`[${attr}]`);
btns.forEach((btn) => {
btn.addEventListener("click", () => {
apply({ [key]: btn.getAttribute(attr) });
});
});
}
function syncButtons() {
const state = readThemeState();
const theme = state.mode === "system" ? (prefersDark() ? "dark" : "light") : state.mode;
document.querySelectorAll("[data-lab-theme]").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.getAttribute("data-lab-theme") === theme ? "true" : "false");
});
document.querySelectorAll("[data-lab-radius]").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.getAttribute("data-lab-radius") === state.radius ? "true" : "false");
});
}
bind("data-lab-theme", "mode");
bind("data-lab-radius", "radius");
syncButtons();
+20
View File
@@ -0,0 +1,20 @@
/**
* Live-reload-клиент для стендов public/_lab.
* Подключается к SSE /_lab/events сервера serve-dev.mjs и перезагружает
* страницу при правке любого css/js/mjs/html в public/.
*
* Вставка в стенд: <script type="module" src="/_lab/live-reload.js"></script>
* Работает только при запущенном `npm run serve` (нет на проде).
*/
(function () {
if (!window.EventSource) return;
const es = new EventSource("/_lab/events");
es.onmessage = (e) => {
if (e.data === "reload") {
es.close();
window.location.reload();
}
};
// Если сервер не поднят — молча уходим, F5 работает как раньше.
es.onerror = () => es.close();
})();
+662
View File
@@ -0,0 +1,662 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="geologica">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Стенд: neu-card</title>
<link rel="stylesheet" href="/css/main.css" />
<link rel="stylesheet" href="/_lab/lab.css" />
</head>
<body>
<div class="page-mesh-bg" aria-hidden="true"></div>
<main class="lab">
<header class="lab__head">
<h1 class="lab__h1">neu-card</h1>
<p class="lab__sub">
Стенд для настройки вида. Сборка не нужна: правим <code>public/css/main.css</code> →
страница перезагружается сама (live-reload). Переключатель темы в правом нижнем углу.
</p>
<nav class="lab__nav" aria-label="Разделы стенда">
<a href="#sec-final">Финал</a>
<a href="#sec-mp">mp-card</a>
<a href="#sec-tokens">Токены</a>
<a href="#sec-bg">Фоны</a>
<a href="#sec-states">Состояния</a>
<a href="#sec-content">Содержимое</a>
<a href="#sec-size">Размеры</a>
<a href="#sec-grid">Сетка</a>
<a href="#sec-nested">Вложенность</a>
<a href="#sec-light">Светлая тема</a>
<a href="#sec-contact">Контакты</a>
<a href="#sec-shader-grid">Матрица</a>
<a href="#sec-all-shaders">Mesh</a>
</nav>
</header>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-final">
<h2 class="lab__h2">Финальный вид</h2>
<p class="lab__note">
Принятые значения: сила объёма <code>0.5</code>, блик <code>0.2</code>, без текстуры.
Обычная неуморфная карточка на нейтральном фоне.
</p>
<div class="lab__row lab__row--wrap lab__depth">
<article class="neu-card neu-card--static">
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Neutral card</span>
<span class="neu-card__meta">Нейтральный фон, выпуклая</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
<button type="button" class="neu-card">
<span class="neu-card__title">Кнопка</span>
<span class="neu-card__meta">Нажми — выпуклость сохраняется</span>
</button>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-mp">
<h2 class="lab__h2">Обычная карточка · mp-card</h2>
<p class="lab__note">
Твоя базовая карточка с сайта — для сравнения с неуморфной рядом. Разметка и стили
взяты из <code>public/css/main.css</code> без изменений.
</p>
<div class="lab__row lab__row--wrap">
<a class="mp-card mp-card--link mp-card--stack" href="#" aria-label="Проект · ссылка">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="/assets/shared/placeholder.webp"
alt=""
width="560"
height="315"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Проект
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">веб-сайт</li>
</ul>
<p class="mp-card__desc">Базовая карточка проекта с обложкой.</p>
</div>
</a>
<div class="mp-card mp-card--stack">
<div class="mp-card__body">
<h3 class="mp-card__title">Текстовая</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">исследование</li>
</ul>
<p class="mp-card__desc">Карточка без обложки — только текст.</p>
</div>
</div>
<a class="mp-card mp-card--link mp-card--compact" href="#" aria-label="Compact · ссылка">
<div class="mp-card__body">
<h3 class="mp-card__title">
Compact
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">дизайн-система</li>
</ul>
<p class="mp-card__desc">Узкая колонка со ссылкой.</p>
</div>
</a>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-tokens">
<h2 class="lab__h2">Токены</h2>
<p class="lab__note">Числа, которыми управляется вид. Значение по умолчанию — в скобках.</p>
<div class="lab__tokens">
<dl class="lab__token">
<dt><code>--neu-bg</code> <span>--bg-elevated</span></dt>
<dd>
Нейтральная заливка: в тёмной теме тёмная, в светлой — светлая. Отличается от
<code>--bg</code> на 3%, поэтому объём читается тенями, а не контрастом.
</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-spread</code> <span>0.5</span></dt>
<dd>Сила объёма. Масштабирует все семь теней разом. <code>0</code> — плоская карточка.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-hover-spread</code> <span>1.5</span></dt>
<dd>Множитель объёма при наведении.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-glow-a</code> <span>0.2</span></dt>
<dd>Сила блика из левого верхнего угла. <code>0</code> — выключен.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-in-light-mult</code> <span>0.55 / 0.4</span></dt>
<dd>
Сила внутреннего блика (тёмная / светлая тема). На светлом фоне белый блик «горит»,
поэтому там он слабее.
</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-in-dark-mult</code> <span>0.75 / 0.5</span></dt>
<dd>Сила внутренней тени (тёмная / светлая тема).</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-press</code> <span>1px</span></dt>
<dd>Смещение вниз при нажатии.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-press-fade</code> <span>0.5</span></dt>
<dd>
Во сколько слабеют внутренние блики при нажатии. Блики НЕ инвертируются — карточка
остаётся выпуклой.
</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-lift</code> <span>-3px</span></dt>
<dd>Подъём при наведении.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-radius</code> <span>--radius-card</span></dt>
<dd>Скругление. По умолчанию следует настройке темы.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-pad</code> <span>1.25rem</span></dt>
<dd>Внутренние отступы.</dd>
</dl>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-bg">
<h2 class="lab__h2">Фоны</h2>
<p class="lab__note">
Неуморфизм читается только в паре с подложкой. Карточка построена от <code>--bg</code>,
поэтому на каждом фоне ведёт себя по-разному.
</p>
<div class="lab__bg-grid">
<div class="lab__bg-cell" data-bg="page">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Страница · <code>--bg</code></p>
</div>
<div class="lab__bg-cell" data-bg="elevated">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Поверхность · <code>--bg-elevated</code></p>
</div>
<div class="lab__bg-cell" data-bg="surface">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Карточка на карточке · <code>--surface</code> — худший случай</p>
</div>
<div class="lab__bg-cell" data-bg="accent">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Акцентная подложка · <code>--accent</code></p>
</div>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-states">
<h2 class="lab__h2">Состояния</h2>
<p class="lab__note">
Наведи курсор и нажми — остальное показано принудительно, чтобы видеть их одновременно.
</p>
<div class="lab__row lab__row--wrap">
<div class="lab__state">
<article class="neu-card">
<span class="neu-card__title">Rest</span>
<span class="neu-card__meta">Покой</span>
</article>
<p class="lab__caption">rest</p>
</div>
<div class="lab__state">
<article class="neu-card">
<span class="neu-card__title">Hover</span>
<span class="neu-card__meta">Наведи курсор</span>
</article>
<p class="lab__caption">:hover</p>
</div>
<div class="lab__state">
<article class="neu-card is-pressed">
<span class="neu-card__title">Pressed</span>
<span class="neu-card__meta">Вдавлена</span>
</article>
<p class="lab__caption">:active / .is-pressed</p>
</div>
<div class="lab__state">
<a class="neu-card" href="#sec-states">
<span class="neu-card__title">Focus</span>
<span class="neu-card__meta">Tab или клик</span>
</a>
<p class="lab__caption">:focus-visible</p>
</div>
<div class="lab__state">
<article class="neu-card is-disabled">
<span class="neu-card__title">Disabled</span>
<span class="neu-card__meta">Недоступна</span>
</article>
<p class="lab__caption">.is-disabled</p>
</div>
<div class="lab__state">
<article class="neu-card neu-card--flat">
<span class="neu-card__title">Flat</span>
<span class="neu-card__meta">Без объёма</span>
</article>
<p class="lab__caption">.neu-card--flat</p>
</div>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-content">
<h2 class="lab__h2">Содержимое</h2>
<p class="lab__note">Проверка наполнения: от пустой до переполненной.</p>
<div class="lab__row lab__row--wrap">
<article class="neu-card">
<span class="neu-card__meta">Только мета</span>
</article>
<article class="neu-card">
<span class="neu-card__title">Только заголовок</span>
</article>
<article class="neu-card">
<span class="neu-card__eyebrow">Eyebrow</span>
<span class="neu-card__title">Полный набор</span>
<span class="neu-card__meta">Подпись под заголовком</span>
</article>
<article class="neu-card">
<span class="neu-card__title">Длинный заголовок в несколько строк</span>
<span class="neu-card__meta">
И подпись тоже длинная, чтобы проверить, как карточка растёт по высоте
</span>
</article>
<article class="neu-card neu-card--with-icon">
<span class="neu-card__icon" aria-hidden="true"></span>
<span class="neu-card__title">С иконкой</span>
<span class="neu-card__meta">Иконка сверху</span>
</article>
<article class="neu-card neu-card--with-media">
<img
class="neu-card__media"
src="/assets/template-elements-demo/small-image-1.webp"
alt=""
loading="lazy"
decoding="async"
/>
<span class="neu-card__title">С обложкой</span>
<span class="neu-card__meta">Картинка на всю ширину</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Акцентная</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-size">
<h2 class="lab__h2">Размеры</h2>
<p class="lab__note">
Базовый размер задаётся <code>--neu-min-w</code> / <code>--neu-min-h</code>, дальше
карточка растёт от содержимого. В сетке — <code>.neu-card--fluid</code>.
</p>
<div class="lab__row lab__row--wrap">
<article class="neu-card" style="--neu-min-w: 160px; --neu-min-h: 96px">
<span class="neu-card__title">Компактная</span>
<span class="neu-card__meta">160 × 96</span>
</article>
<article class="neu-card">
<span class="neu-card__title">Базовая</span>
<span class="neu-card__meta">200 × 128</span>
</article>
<article class="neu-card" style="--neu-min-w: 280px; --neu-min-h: 180px">
<span class="neu-card__title">Крупная</span>
<span class="neu-card__meta">280 × 180</span>
</article>
<article class="neu-card" style="--neu-pad: 2rem">
<span class="neu-card__title">Широкие поля</span>
<span class="neu-card__meta">--neu-pad: 2rem</span>
</article>
<article class="neu-card" style="--neu-radius: 28px">
<span class="neu-card__title">Сильное скругление</span>
<span class="neu-card__meta">--neu-radius: 28px</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-grid">
<h2 class="lab__h2">Сетка</h2>
<p class="lab__note">
Рядом друг с другом тени соседних карточек накладываются. Здесь видно, не слипаются ли.
</p>
<h3 class="lab__h3">Три в ряд, gap 1rem</h3>
<div class="lab__grid lab__grid--3">
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Проект один</span>
<span class="neu-card__meta">Продуктовый дизайн</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Проект два</span>
<span class="neu-card__meta">Исследование</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Проект три</span>
<span class="neu-card__meta">Дизайн-система</span>
</article>
</div>
<h3 class="lab__h3">Смешанный рост</h3>
<p class="lab__note">Разная высота содержимого — проверка на растяжение по сетке.</p>
<div class="lab__grid lab__grid--3">
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Короткая</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Средняя по объёму</span>
<span class="neu-card__meta">
Здесь подпись занимает две строки, и карточка должна потянуться выше соседней
</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">С обложкой</span>
<span class="neu-card__meta">Ещё выше</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-nested">
<h2 class="lab__h2">Карточка в карточке</h2>
<p class="lab__note">
Нижняя карточка больше и включает в себя верхние. Оценка того, как объём читается, когда
карточка лежит <em>поверх той же самой</em> поверхности, а не на фоне страницы. Это худший
случай для неуморфизма: обе поверхности одинаковые по цвету.
</p>
<div class="lab__nest">
<article class="neu-card neu-card--nest">
<span class="neu-card__eyebrow">Внешняя</span>
<span class="neu-card__title">Карточка-контейнер</span>
<span class="neu-card__meta">Поверхность второго уровня, вложенные карточки внутри</span>
<div class="neu-card__nest-grid">
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Вложенная</span>
<span class="neu-card__meta">Тот же --neu-bg</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Вложенная</span>
<span class="neu-card__meta">Тот же --neu-bg</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Вложенная</span>
<span class="neu-card__meta">Тот же --neu-bg</span>
</article>
</div>
<div class="neu-card__nest-row">
<button type="button" class="neu-card">
<span class="neu-card__title">Кнопка</span>
<span class="neu-card__meta">Нажми — не должна проваливаться</span>
</button>
<button type="button" class="neu-card">
<span class="neu-card__title">Кнопка</span>
<span class="neu-card__meta">Выпуклость сохраняется</span>
</button>
</div>
</article>
</div>
<h3 class="lab__h3">То же в светлой теме</h3>
<p class="lab__note">
Внутренние блики настроены раздельно для тёмной и светлой темы — здесь видна разница.
</p>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-light" data-theme="light">
<h2 class="lab__h2">Светлая тема · тень</h2>
<p class="lab__note">
На светлом фоне чистый чёрный 0.25 выглядит грязно. Варианты: цвет тени смещён в
холодный тёмно-серый, альфа внешних теней снижена через <code>--neu-drop-mult</code>.
Эта секция принудительно светлая.
</p>
<div class="lab__row lab__row--wrap lab__depth">
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 47, 53, 75; --neu-drop-mult: 0.5; --neu-in-dark-mult: 0.5">
<span class="neu-card__title">Текущий</span>
<span class="neu-card__meta">47,53,75 · drop 0.5</span>
</article>
<p class="lab__caption">как в main.css</p>
</div>
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 70, 75, 95; --neu-drop-mult: 0.4; --neu-in-dark-mult: 0.4">
<span class="neu-card__title">Мягче</span>
<span class="neu-card__meta">70,75,95 · drop 0.4</span>
</article>
<p class="lab__caption">светлее тень</p>
</div>
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 30, 35, 55; --neu-drop-mult: 0.35; --neu-in-dark-mult: 0.35">
<span class="neu-card__title">Глубже</span>
<span class="neu-card__meta">30,35,55 · drop 0.35</span>
</article>
<p class="lab__caption">темнее, но меньше альфа</p>
</div>
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 0, 0, 0; --neu-drop-mult: 0.5; --neu-in-dark-mult: 0.5">
<span class="neu-card__title">Чёрный</span>
<span class="neu-card__meta">0,0,0 · drop 0.5</span>
</article>
<p class="lab__caption">было грязно</p>
</div>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-contact">
<h2 class="lab__h2">Контактные карточки</h2>
<p class="lab__note">
Оригинальная <code>.contact-card</code> с сайта, теперь с неуморфной основой
(объём как у <code>.neu-card</code>). Бренд-цвета через модификаторы
<code>--brand-tg / --brand-vk / --brand-ya / --brand-email</code>.
Цветное наложение — радиальный градиент <code>::before</code> из <code>--contact-brand</code>.
</p>
<div class="lab__row lab__row--wrap lab__depth contact-grid">
<li>
<a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer">
<span class="contact-card__corner" aria-hidden="true"></span>
<span class="contact-card__icon" aria-hidden="true"><img src="/assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span>
<span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span>
</a>
</li>
<li>
<a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer">
<span class="contact-card__corner" aria-hidden="true"></span>
<span class="contact-card__icon" aria-hidden="true"><img src="/assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span>
<span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span>
</a>
</li>
<li>
<a class="contact-card contact-card--link contact-card--brand-ya" href="https://ya.ru" rel="noopener noreferrer">
<span class="contact-card__corner" aria-hidden="true"></span>
<span class="contact-card__icon" aria-hidden="true"><img src="/assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span>
<span class="contact-card__body"><span class="contact-card__name">Яндекс Телемост</span><span class="contact-card__hint">Видеозвонок</span></span>
</a>
</li>
<li>
<button type="button" class="contact-card contact-card--action contact-card--brand-email">
<span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span>
<span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span>
<span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint">nik@mamadaevv.site</span></span>
</button>
</li>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-shader-grid">
<h2 class="lab__h2">Матрица · neu-card + шейдер под бликом</h2>
<p class="lab__note">
Обычная неуморфная карточка (тени снаружи, блик из угла) с живым шейдером
<strong>внутри</strong>, под бликом. Слева — чистая neu-card для сравнения,
справа — та же карточка с анимированным mesh-слоем. Объём и блик сохранены,
шейдер только подсвечивает поверхность.
</p>
<h3 class="lab__h3">Сетка 3 × 2</h3>
<div class="lab__grid lab__grid--3 lab__matrix">
<!-- чистая neu-card (ряд 1 — образец без шейдера) -->
<article class="neu-card neu-card--fluid">
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Обычная</span>
<span class="neu-card__meta">только тени и блик</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="demo" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · demo</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="email" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · email</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<!-- ряд 2 — разные бренды -->
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="tg" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · tg</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="vk" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · vk</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="ya" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · ya</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
</div>
<h3 class="lab__h3">Одиночная крупная</h3>
<div class="lab__row lab__row--wrap lab__depth">
<article class="neu-card neu-card--shader" style="--neu-min-w: 280px; --neu-min-h: 180px">
<span class="card-shader" data-shader="demo" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Кейс</span>
<span class="neu-card__title">Neu + shader</span>
<span class="neu-card__meta">объём снаружи, анимация внутри</span>
</article>
<article class="neu-card neu-card--static neu-card--shader" style="--neu-min-w: 280px; --neu-min-h: 180px">
<span class="card-shader" data-shader="email" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Кейс</span>
<span class="neu-card__title">Static + shader</span>
<span class="neu-card__meta">блик и анимация, без hover-отклика</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-all-shaders">
<h2 class="lab__h2">Mesh gradient</h2>
<p class="lab__note">
Единственный используемый шейдер — анимированный mesh gradient (цветные пятна
плавно перетекают). Ниже — карточка-пример только с ним. Контактные и матричные
карточки выше используют тот же шейдер с брендовыми цветами.
</p>
<h3 class="lab__h3">Пример · mesh gradient</h3>
<div class="lab__row lab__depth">
<article class="shader-demo-card shader-demo-card--wide" data-shader-cell="demo">
<span class="card-shader" data-shader="demo" aria-hidden="true"></span>
<span class="shader-demo-card__label">mesh gradient</span>
</article>
</div>
</section>
</main>
<div class="lab-switch" role="group" aria-label="Настройки стенда">
<button type="button" class="lab-switch__btn" data-lab-theme="light">Светлая</button>
<button type="button" class="lab-switch__btn" data-lab-theme="dark">Тёмная</button>
<span class="lab-switch__sep" aria-hidden="true"></span>
<button type="button" class="lab-switch__btn" data-lab-radius="soft">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="medium">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="strong">R·</button>
</div>
<script type="module" src="/_lab/lab.js"></script>
<script type="module" src="/_lab/shader-demo.js"></script>
<script type="module" src="/_lab/live-reload.js"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<!DOCTYPE html><html><body style="margin:0;background:#221C89">
<div id="n" style="width:320px;height:320px;background-size:160px 160px;background-image:url(&quot;data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27160%27%20height%3D%27160%27%3E%3Cfilter%20id%3D%27n%27%20x%3D%270%27%20y%3D%270%27%20width%3D%27100%25%27%20height%3D%27100%25%27%20color-interpolation-filters%3D%27sRGB%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%272%27%20numOctaves%3D%273%27%20stitchTiles%3D%27stitch%27%20seed%3D%277403%27%2F%3E%3CfeComponentTransfer%3E%3CfeFuncR%20type%3D%27linear%27%20slope%3D%272%27%20intercept%3D%27-0.5%27%2F%3E%3CfeFuncG%20type%3D%27linear%27%20slope%3D%272%27%20intercept%3D%27-0.5%27%2F%3E%3CfeFuncB%20type%3D%27linear%27%20slope%3D%272%27%20intercept%3D%27-0.5%27%2F%3E%3CfeFuncA%20type%3D%27discrete%27%20tableValues%3D%271%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%27%2F%3E%3C%2FfeComponentTransfer%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%27table%27%20tableValues%3D%270%200.15%27%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Crect%20width%3D%27160%27%20height%3D%27160%27%20filter%3D%27url%28%23n%29%27%2F%3E%3C%2Fsvg%3E&quot;)"></div>
</body></html>
+961
View File
@@ -0,0 +1,961 @@
// node_modules/@paper-design/shaders/dist/vertex-shader.js
var vertexShaderSource = `#version 300 es
precision mediump float;
layout(location = 0) in vec4 a_position;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform float u_imageAspectRatio;
uniform float u_originX;
uniform float u_originY;
uniform float u_worldWidth;
uniform float u_worldHeight;
uniform float u_fit;
uniform float u_scale;
uniform float u_rotation;
uniform float u_offsetX;
uniform float u_offsetY;
out vec2 v_objectUV;
out vec2 v_objectBoxSize;
out vec2 v_responsiveUV;
out vec2 v_responsiveBoxGivenSize;
out vec2 v_patternUV;
out vec2 v_patternBoxSize;
out vec2 v_imageUV;
vec3 getBoxSize(float boxRatio, vec2 givenBoxSize) {
vec2 box = vec2(0.);
// fit = none
box.x = boxRatio * min(givenBoxSize.x / boxRatio, givenBoxSize.y);
float noFitBoxWidth = box.x;
if (u_fit == 1.) { // fit = contain
box.x = boxRatio * min(u_resolution.x / boxRatio, u_resolution.y);
} else if (u_fit == 2.) { // fit = cover
box.x = boxRatio * max(u_resolution.x / boxRatio, u_resolution.y);
}
box.y = box.x / boxRatio;
return vec3(box, noFitBoxWidth);
}
void main() {
gl_Position = a_position;
vec2 uv = gl_Position.xy * .5;
vec2 boxOrigin = vec2(.5 - u_originX, u_originY - .5);
vec2 givenBoxSize = vec2(u_worldWidth, u_worldHeight);
givenBoxSize = max(givenBoxSize, vec2(1.)) * u_pixelRatio;
float r = u_rotation * 3.14159265358979323846 / 180.;
mat2 graphicRotation = mat2(cos(r), sin(r), -sin(r), cos(r));
vec2 graphicOffset = vec2(-u_offsetX, u_offsetY);
// ===================================================
float fixedRatio = 1.;
vec2 fixedRatioBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
v_objectBoxSize = getBoxSize(fixedRatio, fixedRatioBoxGivenSize).xy;
vec2 objectWorldScale = u_resolution.xy / v_objectBoxSize;
v_objectUV = uv;
v_objectUV *= objectWorldScale;
v_objectUV += boxOrigin * (objectWorldScale - 1.);
v_objectUV += graphicOffset;
v_objectUV /= u_scale;
v_objectUV = graphicRotation * v_objectUV;
// ===================================================
v_responsiveBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
float responsiveRatio = v_responsiveBoxGivenSize.x / v_responsiveBoxGivenSize.y;
vec2 responsiveBoxSize = getBoxSize(responsiveRatio, v_responsiveBoxGivenSize).xy;
vec2 responsiveBoxScale = u_resolution.xy / responsiveBoxSize;
#ifdef ADD_HELPERS
v_responsiveHelperBox = uv;
v_responsiveHelperBox *= responsiveBoxScale;
v_responsiveHelperBox += boxOrigin * (responsiveBoxScale - 1.);
#endif
v_responsiveUV = uv;
v_responsiveUV *= responsiveBoxScale;
v_responsiveUV += boxOrigin * (responsiveBoxScale - 1.);
v_responsiveUV += graphicOffset;
v_responsiveUV /= u_scale;
v_responsiveUV.x *= responsiveRatio;
v_responsiveUV = graphicRotation * v_responsiveUV;
v_responsiveUV.x /= responsiveRatio;
// ===================================================
float patternBoxRatio = givenBoxSize.x / givenBoxSize.y;
vec2 patternBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
patternBoxRatio = patternBoxGivenSize.x / patternBoxGivenSize.y;
vec3 boxSizeData = getBoxSize(patternBoxRatio, patternBoxGivenSize);
v_patternBoxSize = boxSizeData.xy;
float patternBoxNoFitBoxWidth = boxSizeData.z;
vec2 patternBoxScale = u_resolution.xy / v_patternBoxSize;
v_patternUV = uv;
v_patternUV += graphicOffset / patternBoxScale;
v_patternUV += boxOrigin;
v_patternUV -= boxOrigin / patternBoxScale;
v_patternUV *= u_resolution.xy;
v_patternUV /= u_pixelRatio;
if (u_fit > 0.) {
v_patternUV *= (patternBoxNoFitBoxWidth / v_patternBoxSize.x);
}
v_patternUV /= u_scale;
v_patternUV = graphicRotation * v_patternUV;
v_patternUV += boxOrigin / patternBoxScale;
v_patternUV -= boxOrigin;
// x100 is a default multiplier between vertex and fragmant shaders
// we use it to avoid UV presision issues
v_patternUV *= .01;
// ===================================================
vec2 imageBoxSize;
if (u_fit == 1.) { // contain
imageBoxSize.x = min(u_resolution.x / u_imageAspectRatio, u_resolution.y) * u_imageAspectRatio;
} else if (u_fit == 2.) { // cover
imageBoxSize.x = max(u_resolution.x / u_imageAspectRatio, u_resolution.y) * u_imageAspectRatio;
} else {
imageBoxSize.x = min(10.0, 10.0 / u_imageAspectRatio * u_imageAspectRatio);
}
imageBoxSize.y = imageBoxSize.x / u_imageAspectRatio;
vec2 imageBoxScale = u_resolution.xy / imageBoxSize;
v_imageUV = uv;
v_imageUV *= imageBoxScale;
v_imageUV += boxOrigin * (imageBoxScale - 1.);
v_imageUV += graphicOffset;
v_imageUV /= u_scale;
v_imageUV.x *= u_imageAspectRatio;
v_imageUV = graphicRotation * v_imageUV;
v_imageUV.x /= u_imageAspectRatio;
v_imageUV += .5;
v_imageUV.y = 1. - v_imageUV.y;
}`;
// node_modules/@paper-design/shaders/dist/shader-mount.js
var DEFAULT_MAX_PIXEL_COUNT = 1920 * 1080 * 4;
var ShaderMount = class {
parentElement;
canvasElement;
gl;
program = null;
uniformLocations = {};
/** The fragment shader that we are using */
fragmentShader;
/** Stores the RAF for the render loop */
rafId = null;
/** Time of the last rendered frame */
lastRenderTime = 0;
/** Total time that we have played any animation, passed as a uniform to the shader for time-based VFX */
currentFrame = 0;
/** The speed that we progress through animation time (multiplies by delta time every update). Allows negatives to play in reverse. If set to 0, rAF will stop entirely so static shaders have no recurring performance costs */
speed = 0;
/** Actual speed used that accounts for visibility: we pause the shader if the tab is hidden or the element out of the viewport */
currentSpeed = 0;
/** Uniforms that are provided by the user for the specific shader being mounted (not including uniforms that this Mount adds, like time and resolution) */
providedUniforms;
/** Names of the uniforms that should have mipmaps generated for them */
mipmaps = [];
/** Just a sanity check to make sure frames don't run after we're disposed */
hasBeenDisposed = false;
/** If the resolution of the canvas has changed since the last render */
resolutionChanged = true;
/** Store textures that are provided by the user */
textures = /* @__PURE__ */ new Map();
minPixelRatio;
maxPixelCount;
isSafari = isSafari();
uniformCache = {};
textureUnitMap = /* @__PURE__ */ new Map();
ownerDocument;
constructor(parentElement, fragmentShader, uniforms, webGlContextAttributes, speed = 0, frame = 0, minPixelRatio = 2, maxPixelCount = DEFAULT_MAX_PIXEL_COUNT, mipmaps = []) {
if (parentElement?.nodeType === 1) {
this.parentElement = parentElement;
} else {
throw new Error("Paper Shaders: parent element must be an HTMLElement");
}
this.ownerDocument = parentElement.ownerDocument;
if (!this.ownerDocument.querySelector("style[data-paper-shader]")) {
const styleElement = this.ownerDocument.createElement("style");
styleElement.innerHTML = defaultStyle;
styleElement.setAttribute("data-paper-shader", "");
this.ownerDocument.head.prepend(styleElement);
}
const canvasElement = this.ownerDocument.createElement("canvas");
this.canvasElement = canvasElement;
this.parentElement.prepend(canvasElement);
this.fragmentShader = fragmentShader;
this.providedUniforms = uniforms;
this.mipmaps = mipmaps;
this.currentFrame = frame;
this.minPixelRatio = minPixelRatio;
this.maxPixelCount = maxPixelCount;
const gl = canvasElement.getContext("webgl2", webGlContextAttributes);
if (!gl) {
throw new Error("Paper Shaders: WebGL is not supported in this browser");
}
this.gl = gl;
this.initProgram();
this.setupPositionAttribute();
this.setupUniforms();
this.setUniformValues(this.providedUniforms);
this.setupResizeObserver();
visualViewport?.addEventListener("resize", this.handleVisualViewportChange);
this.setupIntersectionObserver();
this.setSpeed(speed);
this.parentElement.setAttribute("data-paper-shader", "");
this.parentElement.paperShaderMount = this;
this.ownerDocument.addEventListener("visibilitychange", this.handleDocumentVisibilityChange);
}
initProgram = () => {
const program = createProgram(this.gl, vertexShaderSource, this.fragmentShader);
if (!program) return;
this.program = program;
};
setupPositionAttribute = () => {
const positionAttributeLocation = this.gl.getAttribLocation(this.program, "a_position");
const positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW);
this.gl.enableVertexAttribArray(positionAttributeLocation);
this.gl.vertexAttribPointer(positionAttributeLocation, 2, this.gl.FLOAT, false, 0, 0);
};
setupUniforms = () => {
const uniformLocations = {
u_time: this.gl.getUniformLocation(this.program, "u_time"),
u_pixelRatio: this.gl.getUniformLocation(this.program, "u_pixelRatio"),
u_resolution: this.gl.getUniformLocation(this.program, "u_resolution")
};
Object.entries(this.providedUniforms).forEach(([key, value]) => {
uniformLocations[key] = this.gl.getUniformLocation(this.program, key);
if (value instanceof HTMLImageElement) {
const aspectRatioUniformName = `${key}AspectRatio`;
uniformLocations[aspectRatioUniformName] = this.gl.getUniformLocation(this.program, aspectRatioUniformName);
}
});
this.uniformLocations = uniformLocations;
};
/**
* The scale that we should render at.
* - Used to target 2x rendering even on 1x screens for better antialiasing
* - Prevents the virtual resolution from going beyond the maximum resolution
* - Accounts for the page zoom level so we render in physical device pixels rather than CSS pixels
*/
renderScale = 1;
parentWidth = 0;
parentHeight = 0;
parentDevicePixelWidth = 0;
parentDevicePixelHeight = 0;
devicePixelsSupported = false;
intersectionObserver = null;
isInViewport = true;
resizeObserver = null;
setupResizeObserver = () => {
this.resizeObserver = new ResizeObserver(([entry]) => {
if (entry?.borderBoxSize[0]) {
const physicalPixelSize = entry.devicePixelContentBoxSize?.[0];
if (physicalPixelSize !== void 0) {
this.devicePixelsSupported = true;
this.parentDevicePixelWidth = physicalPixelSize.inlineSize;
this.parentDevicePixelHeight = physicalPixelSize.blockSize;
}
this.parentWidth = entry.borderBoxSize[0].inlineSize;
this.parentHeight = entry.borderBoxSize[0].blockSize;
}
this.handleResize();
});
this.resizeObserver.observe(this.parentElement);
};
setupIntersectionObserver = () => {
const view = this.ownerDocument.defaultView;
if (!view?.IntersectionObserver) return;
this.intersectionObserver = new view.IntersectionObserver(([entry]) => {
this.isInViewport = entry?.isIntersecting ?? true;
this.updateCurrentSpeed();
});
this.intersectionObserver.observe(this.parentElement);
};
// Visual viewport resize handler, mainly used to react to browser zoom changes.
// Resize observer by itself does not react to pinch zoom, and although it usually
// reacts to classic browser zoom, it's not guaranteed in edge cases.
// Since timing between visual viewport changes and resize observer is complex
// and because we'd like to know the device pixel sizes of elements, we just restart
// the observer to get a guaranteed fresh callback regardless if it would have triggered or not.
handleVisualViewportChange = () => {
this.resizeObserver?.disconnect();
this.setupResizeObserver();
};
/** Resize handler for when the container div changes size or the max pixel count changes and we want to resize our canvas to match */
handleResize = () => {
let targetPixelWidth = 0;
let targetPixelHeight = 0;
const dpr = Math.max(1, window.devicePixelRatio);
const pinchZoom = visualViewport?.scale ?? 1;
if (this.devicePixelsSupported) {
const scaleToMeetMinPixelRatio = Math.max(1, this.minPixelRatio / dpr);
targetPixelWidth = this.parentDevicePixelWidth * scaleToMeetMinPixelRatio * pinchZoom;
targetPixelHeight = this.parentDevicePixelHeight * scaleToMeetMinPixelRatio * pinchZoom;
} else {
let targetRenderScale = Math.max(dpr, this.minPixelRatio) * pinchZoom;
if (this.isSafari) {
const zoomLevel = bestGuessBrowserZoom(this.ownerDocument);
targetRenderScale *= Math.max(1, zoomLevel);
}
targetPixelWidth = Math.round(this.parentWidth) * targetRenderScale;
targetPixelHeight = Math.round(this.parentHeight) * targetRenderScale;
}
const maxPixelCountHeadroom = Math.sqrt(this.maxPixelCount) / Math.sqrt(targetPixelWidth * targetPixelHeight);
const scaleToMeetMaxPixelCount = Math.min(1, maxPixelCountHeadroom);
const newWidth = Math.round(targetPixelWidth * scaleToMeetMaxPixelCount);
const newHeight = Math.round(targetPixelHeight * scaleToMeetMaxPixelCount);
const newRenderScale = newWidth / Math.round(this.parentWidth);
if (this.canvasElement.width !== newWidth || this.canvasElement.height !== newHeight || this.renderScale !== newRenderScale) {
this.renderScale = newRenderScale;
this.canvasElement.width = newWidth;
this.canvasElement.height = newHeight;
this.resolutionChanged = true;
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
this.render(performance.now());
}
};
render = (currentTime) => {
if (this.hasBeenDisposed) return;
if (this.program === null) {
console.warn("Tried to render before program or gl was initialized");
return;
}
const dt = currentTime - this.lastRenderTime;
this.lastRenderTime = currentTime;
if (this.currentSpeed !== 0) {
this.currentFrame += dt * this.currentSpeed;
}
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
this.gl.useProgram(this.program);
this.gl.uniform1f(this.uniformLocations.u_time, this.currentFrame * 1e-3);
if (this.resolutionChanged) {
this.gl.uniform2f(this.uniformLocations.u_resolution, this.gl.canvas.width, this.gl.canvas.height);
this.gl.uniform1f(this.uniformLocations.u_pixelRatio, this.renderScale);
this.resolutionChanged = false;
}
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
if (this.currentSpeed !== 0) {
this.requestRender();
} else {
this.rafId = null;
}
};
requestRender = () => {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(this.render);
};
/** Creates a texture from an image and sets it into a uniform value */
setTextureUniform = (uniformName, image) => {
if (!image.complete || image.naturalWidth === 0) {
throw new Error(`Paper Shaders: image for uniform ${uniformName} must be fully loaded`);
}
const existingTexture = this.textures.get(uniformName);
if (existingTexture) {
this.gl.deleteTexture(existingTexture);
}
if (!this.textureUnitMap.has(uniformName)) {
this.textureUnitMap.set(uniformName, this.textureUnitMap.size);
}
const textureUnit = this.textureUnitMap.get(uniformName);
this.gl.activeTexture(this.gl.TEXTURE0 + textureUnit);
const texture = this.gl.createTexture();
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, image);
if (this.mipmaps.includes(uniformName)) {
this.gl.generateMipmap(this.gl.TEXTURE_2D);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
}
const error = this.gl.getError();
if (error !== this.gl.NO_ERROR || texture === null) {
console.error("Paper Shaders: WebGL error when uploading texture:", error);
return;
}
this.textures.set(uniformName, texture);
const location = this.uniformLocations[uniformName];
if (location) {
this.gl.uniform1i(location, textureUnit);
const aspectRatioUniformName = `${uniformName}AspectRatio`;
const aspectRatioLocation = this.uniformLocations[aspectRatioUniformName];
if (aspectRatioLocation) {
const aspectRatio = image.naturalWidth / image.naturalHeight;
this.gl.uniform1f(aspectRatioLocation, aspectRatio);
}
}
};
/** Utility: recursive equality test for all the uniforms */
areUniformValuesEqual = (a, b) => {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
return a.every((val, i) => this.areUniformValuesEqual(val, b[i]));
}
return false;
};
/** Sets the provided uniform values into the WebGL program, can be a partial list of uniforms that have changed */
setUniformValues = (updatedUniforms) => {
this.gl.useProgram(this.program);
Object.entries(updatedUniforms).forEach(([key, value]) => {
let cacheValue = value;
if (value instanceof HTMLImageElement) {
cacheValue = `${value.src.slice(0, 200)}|${value.naturalWidth}x${value.naturalHeight}`;
}
if (this.areUniformValuesEqual(this.uniformCache[key], cacheValue)) return;
this.uniformCache[key] = cacheValue;
const location = this.uniformLocations[key];
if (!location) {
console.warn(`Uniform location for ${key} not found`);
return;
}
if (value instanceof HTMLImageElement) {
this.setTextureUniform(key, value);
} else if (Array.isArray(value)) {
let flatArray = null;
let valueLength = null;
if (value[0] !== void 0 && Array.isArray(value[0])) {
const firstChildLength = value[0].length;
if (value.every((arr) => arr.length === firstChildLength)) {
flatArray = value.flat();
valueLength = firstChildLength;
} else {
console.warn(`All child arrays must be the same length for ${key}`);
return;
}
} else {
flatArray = value;
valueLength = flatArray.length;
}
switch (valueLength) {
case 2:
this.gl.uniform2fv(location, flatArray);
break;
case 3:
this.gl.uniform3fv(location, flatArray);
break;
case 4:
this.gl.uniform4fv(location, flatArray);
break;
case 9:
this.gl.uniformMatrix3fv(location, false, flatArray);
break;
case 16:
this.gl.uniformMatrix4fv(location, false, flatArray);
break;
default:
console.warn(`Unsupported uniform array length: ${valueLength}`);
}
} else if (typeof value === "number") {
this.gl.uniform1f(location, value);
} else if (typeof value === "boolean") {
this.gl.uniform1i(location, value ? 1 : 0);
} else {
console.warn(`Unsupported uniform type for ${key}: ${typeof value}`);
}
});
};
/** Gets the current total animation time from 0ms */
getCurrentFrame = () => {
return this.currentFrame;
};
/** Set a frame to get a deterministic result, frames are literally just milliseconds from zero since the animation started */
setFrame = (newFrame) => {
this.currentFrame = newFrame;
this.lastRenderTime = performance.now();
this.render(performance.now());
};
/** Set an animation speed (or 0 to stop animation) */
setSpeed = (newSpeed = 1) => {
this.speed = newSpeed;
this.updateCurrentSpeed();
};
/** Apply the target speed, pausing (0) while the tab is hidden or the element is out of the viewport */
updateCurrentSpeed = () => {
this.setCurrentSpeed(this.ownerDocument.hidden || !this.isInViewport ? 0 : this.speed);
};
setCurrentSpeed = (newSpeed) => {
this.currentSpeed = newSpeed;
if (this.rafId === null && newSpeed !== 0) {
this.lastRenderTime = performance.now();
this.rafId = requestAnimationFrame(this.render);
}
if (this.rafId !== null && newSpeed === 0) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
};
/** Set the maximum pixel count for the shader, this will limit the number of pixels that will be rendered */
setMaxPixelCount = (newMaxPixelCount = DEFAULT_MAX_PIXEL_COUNT) => {
this.maxPixelCount = newMaxPixelCount;
this.handleResize();
};
/** Set the minimum pixel ratio for the shader */
setMinPixelRatio = (newMinPixelRatio = 2) => {
this.minPixelRatio = newMinPixelRatio;
this.handleResize();
};
/** Update the uniforms that are provided by the outside shader, can be a partial set with only the uniforms that have changed */
setUniforms = (newUniforms) => {
this.setUniformValues(newUniforms);
this.providedUniforms = { ...this.providedUniforms, ...newUniforms };
this.render(performance.now());
};
handleDocumentVisibilityChange = () => {
this.updateCurrentSpeed();
};
/** Dispose of the shader mount, cleaning up all of the WebGL resources */
dispose = () => {
this.hasBeenDisposed = true;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.gl && this.program) {
this.textures.forEach((texture) => {
this.gl.deleteTexture(texture);
});
this.textures.clear();
this.gl.deleteProgram(this.program);
this.program = null;
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, null);
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
this.gl.getError();
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
visualViewport?.removeEventListener("resize", this.handleVisualViewportChange);
this.ownerDocument.removeEventListener("visibilitychange", this.handleDocumentVisibilityChange);
this.uniformLocations = {};
this.canvasElement.remove();
delete this.parentElement.paperShaderMount;
};
};
function createShader(gl, type, source) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vertexShaderSource2, fragmentShaderSource) {
const format = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.MEDIUM_FLOAT);
const precision = format ? format.precision : null;
if (precision && precision < 23) {
vertexShaderSource2 = vertexShaderSource2.replace(/precision\s+(lowp|mediump)\s+float;/g, "precision highp float;");
fragmentShaderSource = fragmentShaderSource.replace(/precision\s+(lowp|mediump)\s+float/g, "precision highp float").replace(/\b(uniform|varying|attribute)\s+(lowp|mediump)\s+(\w+)/g, "$1 highp $3");
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource2);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
if (!vertexShader || !fragmentShader) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Unable to initialize the shader program: " + gl.getProgramInfoLog(program));
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
gl.detachShader(program, vertexShader);
gl.detachShader(program, fragmentShader);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return program;
}
var defaultStyle = `@layer paper-shaders {
:where([data-paper-shader]) {
isolation: isolate;
position: relative;
& canvas {
contain: strict;
display: block;
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
border-radius: inherit;
corner-shape: inherit;
}
}
}`;
function isSafari() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("safari") && !ua.includes("chrome") && !ua.includes("android");
}
function bestGuessBrowserZoom(doc) {
const viewportScale = visualViewport?.scale ?? 1;
const viewportWidth = visualViewport?.width ?? window.innerWidth;
const scrollbarWidth = window.innerWidth - doc.documentElement.clientWidth;
const innerWidth = viewportScale * viewportWidth + scrollbarWidth;
const ratio = outerWidth / innerWidth;
const zoomPercentageRounded = Math.round(100 * ratio);
if (zoomPercentageRounded % 5 === 0) {
return zoomPercentageRounded / 100;
}
if (zoomPercentageRounded === 33) {
return 1 / 3;
}
if (zoomPercentageRounded === 67) {
return 2 / 3;
}
if (zoomPercentageRounded === 133) {
return 4 / 3;
}
return ratio;
}
// node_modules/@paper-design/shaders/dist/shader-utils.js
var declarePI = `
#define TWO_PI 6.28318530718
#define PI 3.14159265358979323846
`;
var rotation2 = `
vec2 rotate(vec2 uv, float th) {
return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv;
}
`;
var textureRandomizerR = `
float randomR(vec2 p) {
vec2 uv = floor(p) / 100. + .5;
return texture(u_noiseTexture, fract(uv)).r;
}
`;
var fiberNoise = `
float fiberRandom(vec2 p) {
vec2 uv = floor(p) / 100.;
return texture(u_noiseTexture, fract(uv)).b;
}
float fiberValueNoise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = fiberRandom(i);
float b = fiberRandom(i + vec2(1.0, 0.0));
float c = fiberRandom(i + vec2(0.0, 1.0));
float d = fiberRandom(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
float x1 = mix(a, b, u.x);
float x2 = mix(c, d, u.x);
return mix(x1, x2, u.y);
}
float fiberNoiseFbm(in vec2 n, vec2 seedOffset) {
float total = 0.0, amplitude = 1.;
for (int i = 0; i < 4; i++) {
n = rotate(n, .7);
total += fiberValueNoise(n + seedOffset) * amplitude;
n *= 2.;
amplitude *= 0.6;
}
return total;
}
float fiberNoise(vec2 uv, vec2 seedOffset) {
float epsilon = 0.001;
float n1 = fiberNoiseFbm(uv + vec2(epsilon, 0.0), seedOffset);
float n2 = fiberNoiseFbm(uv - vec2(epsilon, 0.0), seedOffset);
float n3 = fiberNoiseFbm(uv + vec2(0.0, epsilon), seedOffset);
float n4 = fiberNoiseFbm(uv - vec2(0.0, epsilon), seedOffset);
return length(vec2(n1 - n2, n3 - n4)) / (2.0 * epsilon);
}
`;
// node_modules/@paper-design/shaders/dist/shaders/paper-texture.js
var paperTextureFragmentShader = `#version 300 es
precision mediump float;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform vec4 u_colorFront;
uniform vec4 u_colorBack;
uniform sampler2D u_image;
uniform float u_imageAspectRatio;
uniform float u_contrast;
uniform float u_roughness;
uniform float u_fiber;
uniform float u_fiberSize;
uniform float u_crumples;
uniform float u_crumpleSize;
uniform float u_folds;
uniform float u_foldCount;
uniform float u_drops;
uniform float u_seed;
uniform float u_fade;
uniform sampler2D u_noiseTexture;
in vec2 v_imageUV;
out vec4 fragColor;
float getUvFrame(vec2 uv) {
float aax = 2. * fwidth(uv.x);
float aay = 2. * fwidth(uv.y);
float left = smoothstep(0., aax, uv.x);
float right = 1. - smoothstep(1. - aax, 1., uv.x);
float bottom = smoothstep(0., aay, uv.y);
float top = 1. - smoothstep(1. - aay, 1., uv.y);
return left * right * bottom * top;
}
${declarePI}
${rotation2}
${textureRandomizerR}
float valueNoise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = randomR(i);
float b = randomR(i + vec2(1.0, 0.0));
float c = randomR(i + vec2(0.0, 1.0));
float d = randomR(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
float x1 = mix(a, b, u.x);
float x2 = mix(c, d, u.x);
return mix(x1, x2, u.y);
}
float fbm(vec2 n) {
float total = 0.0, amplitude = .4;
for (int i = 0; i < 3; i++) {
total += valueNoise(n) * amplitude;
n *= 1.99;
amplitude *= 0.65;
}
return total;
}
float randomG(vec2 p) {
vec2 uv = floor(p) / 50. + .5;
return texture(u_noiseTexture, fract(uv)).g;
}
float roughness(vec2 p) {
p *= .1;
float o = 0.;
for (float i = 0.; ++i < 4.; p *= 2.1) {
vec4 w = vec4(floor(p), ceil(p));
vec2 f = fract(p);
o += mix(
mix(randomG(w.xy), randomG(w.xw), f.y),
mix(randomG(w.zy), randomG(w.zw), f.y),
f.x);
o += .2 / exp(2. * abs(sin(.2 * p.x + .5 * p.y)));
}
return o / 3.;
}
${fiberNoise}
vec2 randomGB(vec2 p) {
vec2 uv = floor(p) / 50. + .5;
return texture(u_noiseTexture, fract(uv)).gb;
}
float crumpledNoise(vec2 t, float pw) {
vec2 p = floor(t);
float wsum = 0.;
float cl = 0.;
for (int y = -1; y < 2; y += 1) {
for (int x = -1; x < 2; x += 1) {
vec2 b = vec2(float(x), float(y));
vec2 q = b + p;
vec2 q2 = q - floor(q / 8.) * 8.;
vec2 c = q + randomGB(q2);
vec2 r = c - t;
float w = pow(smoothstep(0., 1., 1. - abs(r.x)), pw) * pow(smoothstep(0., 1., 1. - abs(r.y)), pw);
cl += (.5 + .5 * sin((q2.x + q2.y * 5.) * 8.)) * w;
wsum += w;
}
}
return pow(wsum != 0.0 ? cl / wsum : 0.0, .5) * 2.;
}
float crumplesShape(vec2 uv) {
return crumpledNoise(uv * .25, 16.) * crumpledNoise(uv * .5, 2.);
}
vec2 folds(vec2 uv) {
vec3 pp = vec3(0.);
float l = 9.;
for (float i = 0.; i < 15.; i++) {
if (i >= u_foldCount) break;
vec2 rand = randomGB(vec2(i, i * u_seed));
float an = rand.x * TWO_PI;
vec2 p = vec2(cos(an), sin(an)) * rand.y;
float dist = distance(uv, p);
l = min(l, dist);
if (l == dist) {
pp.xy = (uv - p.xy);
pp.z = dist;
}
}
return mix(pp.xy, vec2(0.), pow(pp.z, .25));
}
float drops(vec2 uv) {
vec2 iDropsUV = floor(uv);
vec2 fDropsUV = fract(uv);
float dropsMinDist = 1.;
for (int j = -1; j <= 1; j++) {
for (int i = -1; i <= 1; i++) {
vec2 neighbor = vec2(float(i), float(j));
vec2 offset = randomGB(iDropsUV + neighbor);
offset = .5 + .5 * sin(10. * u_seed + TWO_PI * offset);
vec2 pos = neighbor + offset - fDropsUV;
float dist = length(pos);
dropsMinDist = min(dropsMinDist, dropsMinDist*dist);
}
}
return 1. - smoothstep(.05, .09, pow(dropsMinDist, .5));
}
float lst(float edge0, float edge1, float x) {
return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
}
void main() {
vec2 imageUV = v_imageUV;
vec2 patternUV = v_imageUV - .5;
patternUV = 5. * (patternUV * vec2(u_imageAspectRatio, 1.));
vec2 roughnessUv = 1.5 * (gl_FragCoord.xy - .5 * u_resolution) / u_pixelRatio;
float roughness = roughness(roughnessUv + vec2(1., 0.)) - roughness(roughnessUv - vec2(1., 0.));
vec2 crumplesUV = fract(patternUV * .02 / u_crumpleSize - u_seed) * 32.;
float crumples = u_crumples * (crumplesShape(crumplesUV + vec2(.05, 0.)) - crumplesShape(crumplesUV));
vec2 fiberUV = 2. / u_fiberSize * patternUV;
float fiber = fiberNoise(fiberUV, vec2(0.));
fiber = .5 * u_fiber * (fiber - 1.);
vec2 normal = vec2(0.);
vec2 normalImage = vec2(0.);
vec2 foldsUV = patternUV * .12;
foldsUV = rotate(foldsUV, 4. * u_seed);
vec2 w = folds(foldsUV);
foldsUV = rotate(foldsUV + .007 * cos(u_seed), .01 * sin(u_seed));
vec2 w2 = folds(foldsUV);
float drops = u_drops * drops(patternUV * 2.);
float fade = u_fade * fbm(.17 * patternUV + 10. * u_seed);
fade = clamp(8. * fade * fade * fade, 0., 1.);
w = mix(w, vec2(0.), fade);
w2 = mix(w2, vec2(0.), fade);
crumples = mix(crumples, 0., fade);
drops = mix(drops, 0., fade);
fiber *= mix(1., .5, fade);
roughness *= mix(1., .5, fade);
normal.xy += u_folds * min(5. * u_contrast, 1.) * 4. * max(vec2(0.), w + w2);
normalImage.xy += u_folds * 2. * w;
normal.xy += crumples;
normalImage.xy += 1.5 * crumples;
normal.xy += 3. * drops;
normalImage.xy += .2 * drops;
normal.xy += u_roughness * 1.5 * roughness;
normal.xy += fiber;
normalImage += u_roughness * .75 * roughness;
normalImage += .2 * fiber;
vec3 lightPos = vec3(1., 2., 1.);
float res = dot(normalize(vec3(normal, 9.5 - 9. * pow(u_contrast, .1))), normalize(lightPos));
vec3 fgColor = u_colorFront.rgb * u_colorFront.a;
float fgOpacity = u_colorFront.a;
vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
float bgOpacity = u_colorBack.a;
imageUV += .02 * normalImage;
float frame = getUvFrame(imageUV);
vec4 image = texture(u_image, imageUV);
image.rgb += .6 * pow(u_contrast, .4) * (res - .7);
frame *= image.a;
vec3 color = fgColor * res;
float opacity = fgOpacity * res;
color += bgColor * (1. - opacity);
opacity += bgOpacity * (1. - opacity);
opacity = mix(opacity, 1., frame);
color -= .007 * drops;
color.rgb = mix(color, image.rgb, frame);
fragColor = vec4(color, opacity);
}
`;
// node_modules/@paper-design/shaders/dist/get-shader-noise-texture.js
function getShaderNoiseTexture() {
if (typeof window === "undefined") {
return void 0;
}
const img = new Image();
img.src = noiseSrc;
return img;
}
var noiseSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAADAFBMVEUCAQMBAf7/AgMD/wID//7+/wT+A/4FAmYIAqIKnw7+//4EAisEAUgGBIYIewkFVhEJjAoFAuEFA8GWAv6T/gz+AzER/25z/wu1/w1nAggL/049BQUC/y39BrckAQQp/wr+AZYNOvx9AQkN/pELUvMFaAZTBAgIRgsO/7cJNQT+YgkLwRELIf5O/wlP/v79/q4IGAYLK4+kAQ1tAv4IdMpc/4xNMBF2/lQN2vTFAws9BLf9/3kJJgsMRF3+HwkLxfv9BVL8BHEN/9gMsg7cA/13/vv9OAqWA0sOofP9TAsIe/4FQqoF4Q/aAgsQwnKQAwa5BP0JW21NqgmY/f3Z/wkI7whGjAr7oAkLrGGf/JH8jg4zAj4R0Qr+xQ8VZv1Y/8O6//wfA/5bAT79/lQ1AGn8egkKdom0BgYOsfjtBAVDBoz9/zG0A238P/tsbQ/+A9rIig/HCEtvIgrM/1lwBWgIlmr62Q5qA5FndnEIXa+PthUMrqiRfw6SAodE/0cQm6UOirP5swuMCrEOjvo/dBVSA/79KvCgSBL9M1E/TwjUag/e//2WdPZ2TQ9ZMvfPxRD7aPpmOFqXSPu3pww5B/wR00wTgVf3y6dXW137ffv3c7GNj/icJG+4xvYQ61++CZOVll8p//uXzgyTKg6m/1L47w3cAY8EI1T7xvgKbkr7UsGBJPNsB7xL2wuvd5z3svmDmgipcGT8jez8oP0R6bNYuVpUxRn9LZVkqIijYxK7K/dZBtjH/71ZT/1myfz52fVm2WBfk0vxUFj+Vfv9/9plbfz3yl6VUl+flbNijrpfpfz5TZSGRKAI15X14pSt4vwQKMHOTQlKifz1sKW6A9u2A7R65waprffGcfeY/8iyUsFh3rn4lGERMUHJolveAs+PBdb5iZFuX8S8SH7Ekfe8Lwy0t5cLwsD3s2TzbHXa/478nLtNQ6NtstW15QvaKgr25FJm4vyXwFlPInIPId79dUr77fmr18BGdLHIS/mGx6dKw64L7v6k32XMJrWl8ELA3C70AAAgAElEQVR42gTBCTyUeQMA4P97zIx3ZjDvHGaMYQxjhhm33BGTY8h95sodkaNkXVGhKGdUri+SIxQ6nG36VUhS0rnZ6tsVfR2ibKlta7/d5wH7kMaTxlOVozEoHgU29/ayNC9YlrZdyVT+Lf/dAsDDc/xfzX+MLBa2LK23goK0aXhCxZ8qIAdXYj+c8zviDOtRkhEtRxNajHWLuCtdcfQqV2mgRlpDD6wJpKpBrGON27qa4nNeQOU8ViU0pZ2eCMN5mWO7bfR17Q9ItpsqgZJNJcJSq6cSWiV4q1zIDMmkqzAdpqT8gI5G3qm3YEyliPPG9kiwF7P99ghNn7zLs9EXFvFdLmlOdKBAp2ZyGTcI4JuBPYrWyGCYwgFwOhTmHeYC0zEDSp1iX3W71cqoW332M++OAYJUrEySVX0c5lzmDgLcAQ1yFVVOgQ5l+j1k6TEBidTUek7OF4T2kDYo2eVGwOrglKyGBXYyBrxFv9ptR16B+BJ0IFCsryJve0ZEuzNjLeEcw/0aK/kyku6JW0BiicnCBFptKAQRRNRrtmUV/YOn6GNMHXddsFf1YZCHMnFWgcyp2gnLOWTTBcVQVvM/FTgJAHl0NWHHzL0eqzuRXTDCEO03DoThV3kezhrtpNqKW0Bb3MSSAJMmmVnLEpexS8JrmYOr4KXz1cUmByty3N/sbEzBSP8tfGSCJ3caYDhymsPdGbwO4HAl/+PYDCZNf+H6kofkNk4N4Zn6NM4y1lJD7Tt2gyklnrR48dgbfHXgd9uzHvpamm3wKhcaLcawXWxL5T97dL7MeW3aZ7NDWksVZyZv8VQyjm94CDU7UjtbedqOCvB2DdE+wFC6a5JcEIgkKRJ8cfTGmW/2jMS5LEWWKiGY0BFaDNQ++2+sOifPMQ7CcHeFx+PPpcbzRoy4IKmVwHg/1842BwoGc2qlRVoNjCF59oXsrcBgVEP4u1GIX7jshIMqqPdbGTRJzMXcyyyiNG5fr5qFrUVntrktt4QdJugkr1kzNJCK1roWpTraix9JVMpZcsxGYsJlGiSyEgOFZzHy6YVlilnicmxUVkdX/PetzMBk92PNJNkIaLhmA30XPCrMuncWxOZK9kpLnqpYOOsLFFmaf2Mk8OH+BbwPH7HBX2KGI0Ns80gleH+Y6k0YZcF0sWgpoJA30BBbG59XaKyBHoxFtc2p9sFvyXqo2v2aRKN+1HLPshCibfZESAESYsLXmz3tT4wNMp0Wali+VPN93JIJaQ0AcXGrNMnSS0YASPcaNh32NhO0sWHKPhrNVpCBzyk4EWR/PnmKE+3s2cDO+YF6OddPNx7G4AIrZBPldw6tcss4bqzb6hBy6ccf3YaBSNRBFELueRFp7DXWNMFVAT9J1LNTntEyEI2gJS64oyKMKvSRrbpPQGE0rEEmHyqCl2oQravq51FwJXG0m/pPdRA6Xp3sSLdwGwNytaLg3g3VEE2eFESy/GijQPwmYPjwJT+bH/ax0dNT0NZAFQxyIqKzET00vUDuJ+T25QGCclaGZiJBxsjtz3YMZ0PPsq751h0ldwbZstMgHfnauk/7n1eZxEmYIPf5wPt0KJvg2V9bcYWGgua/Lvn/xG5q98tPLcGzHaac2+Cbs3niyPtGgfYgBT2OHgxvhGxzApoPxPoCOtUNCXX+ojW0ug7DOuyrOOG5GkWhaAzx6ZyGE8qbCPS1oxzPjcWSrG/ICNaNMKsra8bIlQVvmRQ/FY4WiHhnrVz/VfdOiOu6u66gG3NKogJ/0rGdbC+iPN1pbZ4HQAZODS+mC2z9dNBqSzd6mTQWKq+EI3fXgJQdqfqz6jY6Fbs4sWT/QkaLUOBnMhWRmSdrpTy769BcCql1UOmaqtFbDA9d7qEox8Lpa+TPXX+xm40jrB7EBK1lwu6IMud9xh7NBZCbq6PNN/QdTu0BVa2neF+s8b1dGns5tMGxQIP/+fiY60jZNp9n5D9MLm4NLWO2gXVG4xwDXHeHXMFEAITOVUGJRoBUwOV3miiTEPPzLrwDm74zFsW9zkfCASQvPi2RaF9qJ2HHWMJNxCHzDym6tNfXiEe28ZnjmHVGwlSvfgBo4afqcoTh4NNq7QQ1KrPJW+1uHEK1VvTghGa0DAePo8D6D1NCYgEPY239D/RQSUMxWJsAIi5KEp/3/9LH1wSTwl8/mfekwWyIhAwMPErzWxVSL7sFnFT1NqJ+Zb8hX4cqwyucXdUVkaqNeVL7abNtJV++aASn/d+Fw9qlVwplz4SqpVw5CBK7nq483nxbZ8p/8TtFwr8oD5uhq+lxfovd0x4+MHo1Wv14SJzqBo9Un1KCZ8NWfbA7jLeoMjnCcS8bjtKuxii0+0RPZlLS6NdhNKHeN2NSdCswa+K+aGFUTD9MLW9R7mhPT5i88TZvV5rWtuek07W/vBev9eJznPGkM8FrCZ53AB8+Ig7vKms99yRb5fpyoQssijTwz0i22O+HvjsjyGXpqseb4t4j6YW86PfJF2cnjmy8EKVF8sIomGUdVGBquOIDIlHsrgPkJEzw7KovqHB/kS+NPgs9nG9FkG1MJiA0GNwTyj5dRS0uiWTfSLf7jpL0ioLExajL/OJPkUbA6CIdKjpU6XrSY/6mE5Z1IDBoHX7tGx9fFkJZQPrPIW49pj9oUEykkiolzaein8mBh/C/0eAzYoFXHWJxYZWrv/ayPmcWsjfWyDy8ndnmPTldcJ05MaxOoIHWPcND2SOan44Wc1Oxyk59KHbiXwbrxB3qvAEA+Pd3zc3MkDFmxjG3K4ZxjHHfFXKNI691kyRLjmRCUmTQWnQo6XS8JNFBsTkqiRQpijalraTe1VPbpa1394/4PM+naUIl5jb9OQw4tXHsFyAoD/x8vmlYJu23hfowcTnJOXSMUdKum4IqKUd4HJguRiprd/Etw9K/NJ+UKE+T2v39ms2JRGhtNDxShw6kmZEdsr6fwVSzZUCgj/xK8CaD46MMqjtVmEE0DTPS7yo7so402lkAAr5A9TA8YbapYO+4tLHK+uBAqCsdrmkNB/tSNQxgrZRiBjhVSt904TQbBmEDW36UhZEwZN9TbWh1vtrLVYdkQKayJHgjO5aVftyaOhbtIVFjq0gImWcFJbXqPp+aGTaOzHzPptvWbli/tEz5BHs2WdU4y01sOWIdG+CPWbxSDnQ/KbYgddG1ggtPPUFvXeLdNH2EoslAveJl8GUVaLs6WWsoo3G2Q8KnvSkrNV13rJm4fF2jG2NKE3FMgjWPyCyVVZXDxk0WKQyzIcdGvhovfXwvS237WZN3PvX9Dh50V1CMuemc5AkPWBJzzlg8giqz/M3mICBajNsO3PSuByw3zV51gCTybHlfu/R+zXwVekhzN1C0gZCgqc3x8EUR5Mt8LndPRv3AbLnf2ZMLJ2TZBapthY8hSsIET5/vpH1T7/l1IKZl4pTp2eMVFT8J+1JyElnizM32GmBQTaTDJOwuvPCV3QDonD/6xjwgR6SA92MF+v+Xlo/BDyOZJpkM7QFh73uKxzX9hlDol/x5HVESyPM/HNyF6MwCg866UWXm9Jd2xsjrXyEKgjl11K41nEwzFzjyP0V9T87dStAustB/MkOwBaQoOCNG0+6dfSw2YIL2d+aAFbtewoPIATWJC+6il2nDFDx8Vlxg2a22oZG4My48gnrQEcDxOuE71wz51mkfvC3B8gjF04baNRpg6SGoHIAc+zB2Qqqn9yEzCXfpmpdN2kxdkiMQ/W/X7iT/RzkpBGvlGrx2Bs4pl3s8Akl3mRTsubk3x+CQH47r1ZNgECzf7IP0nV8lRUj1XqsW9+wNI0+oAx/lOGVsHcmalqdAqT/Rb+rp3wthEPxjXI6irxhTZc9U20OHSbYAJCX6MKHYW/P8X Line truncated
export {
ShaderMount,
getShaderNoiseTexture,
paperTextureFragmentShader
};
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>bake</title></head>
<body style="margin:0">
<div id="host" style="width:512px;height:512px"></div>
<script type="module">
import { ShaderMount, paperTextureFragmentShader, getShaderNoiseTexture } from "/_lab/paper-bundle.js";
try {
const host = document.getElementById("host");
const t0 = performance.now();
const noise = getShaderNoiseTexture();
await noise.decode();
const mount = new ShaderMount(
host,
paperTextureFragmentShader,
{
u_colorBack: [1, 1, 1, 1],
u_colorFront: [0, 0, 0, 1],
u_contrast: 0.3,
u_roughness: 0.4,
u_fiber: 0.3,
u_fiberSize: 0.2,
u_crumples: 0.3,
u_crumpleSize: 0.35,
u_folds: 0.65,
u_foldCount: 5,
u_drops: 0.2,
u_fade: 0,
u_seed: 5.8,
u_scale: 0.6,
u_noiseTexture: noise,
},
{ antialias: false },
0,
0,
1,
);
// ждём, пока текстура шума реально загрузится и кадр отрендерится
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = host.querySelector("canvas");
// переливаем в свой canvas, чтобы toDataURL дал полный кадр
const out = document.createElement("canvas");
out.width = canvas.width;
out.height = canvas.height;
out.id = "out";
out.getContext("2d").drawImage(canvas, 0, 0);
document.body.appendChild(out);
window.__T = Math.round(performance.now() - t0);
window.__READY = true;
} catch (e) {
window.__ERR = e.message;
}
</script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
// Живой прототип Paper Shaders на стенде _lab (без правки прод-кода).
// Пакет скопирован локально в /vendor/paper-shaders (zero-dep, ESM).
import {
ShaderMount,
meshGradientFragmentShader,
getShaderColorFromString,
defaultObjectSizing,
ShaderFitOptions,
} from '/vendor/paper-shaders/index.js';
// Цвета по бренду (как у .contact-card / .neu-card--shader)
const BRAND = {
tg: ['#0088cc', '#00bcd4', '#0a66c2'],
vk: ['#0077ff', '#4a90ff', '#0033aa'],
ya: ['#f8604a', '#ffae00', '#ff3d00'],
email: ['#7c5cff', '#b06cff', '#5a3fd6'],
demo: ['#7c5cff', '#00c2c7', '#ff5c8a', '#ffb347'],
};
// Официальная конвертация hex/HSL/RGB -> [r,g,b,a] (0..1), как ожидает u_colors (vec4[]).
const c4 = (arr) => arr.map(getShaderColorFromString);
// Sizing uniforms обязательны (vertex-шейдер делит на u_scale — при 0 будет NaN
// и весь шейдер чёрный). Берём дефолты из пакета, как в React-обёртке.
const sizing = () => ({
u_fit: ShaderFitOptions[defaultObjectSizing.fit],
u_scale: defaultObjectSizing.scale,
u_rotation: defaultObjectSizing.rotation,
u_offsetX: defaultObjectSizing.offsetX,
u_offsetY: defaultObjectSizing.offsetY,
u_originX: defaultObjectSizing.originX,
u_originY: defaultObjectSizing.originY,
u_worldWidth: defaultObjectSizing.worldWidth,
u_worldHeight: defaultObjectSizing.worldHeight,
});
// Единственный используемый шейдер: mesh gradient, анимированный.
const MESH = {
frag: meshGradientFragmentShader,
speed: 1.0,
uniforms: (c) => ({
...sizing(),
u_colors: c4(c), u_colorsCount: c.length,
u_distortion: 1.2, u_swirl: 0.8, u_grainMixer: 0.08, u_grainOverlay: 0.04,
}),
};
const mounts = new WeakMap();
function mountShader(host, colors) {
const wrap = host.querySelector('.card-shader');
if (!wrap) return;
if (mounts.has(host)) { try { mounts.get(host).dispose(); } catch {} mounts.delete(host); }
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
try {
const m = new ShaderMount(wrap, MESH.frag, MESH.uniforms(colors),
{ preserveDrawingBuffer: true, antialias: true }, MESH.speed);
// отключаем IntersectionObserver: иначе вне вьюпорта ставит currentSpeed=0
// и рендер замирает (у пользователя карточка внизу страницы -> пауза).
try { m.intersectionObserver?.disconnect(); } catch {}
mounts.set(host, m);
} catch (e) {
console.error('ShaderMount failed', e);
}
}
function mountAll(root = document) {
// любые карточки с шейдером: у .card-shader есть data-shader (бренд),
// сама карточка — его родитель (contact-card, neu-card--shader, shader-cell...)
root.querySelectorAll('.card-shader[data-shader]').forEach((wrap) => {
const host = wrap.parentElement;
const brand = wrap.dataset.shader;
mountShader(host, BRAND[brand] || BRAND.demo);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountAll);
} else {
mountAll();
}
window.__mountLabShaders = mountAll;
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="geologica">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Стенд: &lt;название элемента&gt;</title>
<!--
Базовый шаблон песочницы _lab.
Правила:
- Сборка НЕ нужна. Правишь public/css/main.css или этот файл → страница сама
перезагрузится (live-reload сервера npm run serve).
- Стили компонента живут в public/css/main.css. Сюда пишешь только обвязку стенда.
- Скрипт темы (lab.js) переиспользуется как есть — даёт переключатель темы/радиуса.
- Чтобы добавить новый стенд: скопируй этот файл в public/_lab/<имя>.html,
поправь заголовок и секции. Готовый элемент потом встрой в src/pages/**.
-->
<link rel="stylesheet" href="/css/main.css" />
<link rel="stylesheet" href="/_lab/lab.css" />
</head>
<body>
<div class="page-mesh-bg" aria-hidden="true"></div>
<main class="lab">
<header class="lab__head">
<h1 class="lab__h1">&lt;название элемента&gt;</h1>
<p class="lab__sub">
Стенд для настройки вида. Сборка не нужна: правим <code>public/css/main.css</code> →
страница перезагрузится сама. Переключатель темы в правом нижнем углу.
</p>
<nav class="lab__nav" aria-label="Разделы стенда">
<a href="#sec-demo">Демо</a>
<a href="#sec-tokens">Токены</a>
</nav>
</header>
<!-- Копируй эту секцию под каждый аспект настройки -->
<section class="lab__sec" id="sec-demo">
<h2 class="lab__h2">Демо</h2>
<p class="lab__note">Опиши, что смотрим. Здесь — сам элемент во всех состояниях.</p>
<div class="lab__row lab__row--wrap lab__depth">
<!-- Пример карточки из готового компонента neu-card: -->
<article class="neu-card">
<span class="neu-card__title">Состояние</span>
<span class="neu-card__meta">Подпись</span>
</article>
</div>
</section>
<section class="lab__sec" id="sec-tokens">
<h2 class="lab__h2">Токены</h2>
<p class="lab__note">Числа, которыми управляется вид. Значение по умолчанию — в скобках.</p>
<div class="lab__tokens">
<dl class="lab__token">
<dt><code>--neu-bg</code> <span>--bg-elevated</span></dt>
<dd>Нейтральная заливка карточки.</dd>
</dl>
</div>
</section>
</main>
<div class="lab-switch" role="group" aria-label="Настройки стенда">
<button type="button" class="lab-switch__btn" data-lab-theme="light">Светлая</button>
<button type="button" class="lab-switch__btn" data-lab-theme="dark">Тёмная</button>
<span class="lab-switch__sep" aria-hidden="true"></span>
<button type="button" class="lab-switch__btn" data-lab-radius="soft">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="medium">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="strong">R·</button>
</div>
<script type="module" src="/_lab/lab.js"></script>
<script type="module" src="/_lab/live-reload.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.656 6.34316C14.5318 3.21897 9.46648 3.21897 6.34232 6.34316C3.74758 8.93789 3.30781 12.8715 5.02309 15.92L4.24314 19.8354L8.2061 19.046C11.2362 20.6787 15.0971 20.2157 17.656 17.6569C20.7802 14.5326 20.7802 9.46742 17.656 6.34316Z" fill="#3FC352"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5679 13.3968C15.171 13.1597 14.6549 12.8947 14.1879 13.0859C13.8292 13.2327 13.6002 13.7937 13.3682 14.0802C13.2488 14.2273 13.1068 14.2502 12.9236 14.1764C11.5779 13.6404 10.5468 12.7422 9.80433 11.5046C9.67863 11.312 9.70099 11.1605 9.85281 10.9821C10.0772 10.7182 10.3588 10.4182 10.4196 10.0622C10.4799 9.70616 10.3133 9.29018 10.1658 8.97322C9.97778 8.56833 9.76779 7.99077 9.36195 7.76164C8.98897 7.55093 8.49821 7.66875 8.16553 7.93941C7.59239 8.40645 7.31553 9.13798 7.32392 9.86342C7.32638 10.0694 7.35168 10.2751 7.39979 10.4739C7.51539 10.9524 7.73658 11.3991 7.98526 11.8237C8.17293 12.144 8.37673 12.4545 8.59682 12.7534C9.31644 13.7312 10.212 14.5808 11.2487 15.2158C11.7669 15.5332 12.3247 15.8114 12.9026 16.0023C13.5506 16.2163 14.1279 16.4393 14.8275 16.3063C15.5602 16.1672 16.2827 15.7138 16.5733 15.008C16.6592 14.7987 16.7027 14.566 16.6543 14.3452C16.5549 13.889 15.9368 13.6177 15.5679 13.3968Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+241 -226
View File
@@ -8,101 +8,101 @@
content="Кейс: AI assistant — плагин для Figma с AI-функциями."
/>
<title>AI assistant — Плагин для Figma — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -176,101 +176,101 @@
</script>
</head>
<body data-current-case="ai-assistant">
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../index.html" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
@@ -308,7 +308,7 @@
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="ai-assistant">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
@@ -323,7 +323,7 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="card-columns">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
@@ -338,7 +338,7 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="site-with-ai-ide">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
@@ -357,44 +357,59 @@
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Контакты</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Яндекс</span><span class="contact-card__hint">Да, тут тоже можно</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Контакты</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section class="section section--surface" aria-hidden="true">
<div class="section__inner"></div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true"> · </span><nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span>
</div>
</footer>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../../cookie-policy/" data-cookie-policy-link>политике cookie</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+655 -248
View File
@@ -5,104 +5,104 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: Card Columns — плагин для колоночного отображения карточек в Obsidian."
content="Кейс: Card Columns, плагин-вид для Obsidian Bases. Показывает заметки как карточки, разложенные по колонкам, с фильтрацией тегов по И/ИЛИ."
/>
<title>Card Columns — Плагин для Obsidian — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<title>Card Columns, плагин для Obsidian Bases: кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -124,9 +124,100 @@
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-article .case-brief {
list-style: none;
margin: 1.5rem 0 0;
padding: 1.1rem 1.25rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.85rem 1.5rem;
font-size: 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-md, 12px);
}
.case-article .case-brief li {
margin: 0;
}
.case-article .case-brief span {
display: block;
margin-bottom: 0.15rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.case-article .case-todo {
margin: 1rem 0 1.25rem;
padding: 0.9rem 1.1rem;
border-left: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 var(--radius-sm, 8px) var(--radius-sm, 8px) 0;
font-size: 0.92rem;
}
.case-article .case-todo p {
margin: 0 0 0.5rem;
}
.case-article .case-todo p:last-child {
margin-bottom: 0;
}
.case-article .case-todo__q {
font-weight: 600;
color: var(--accent);
}
.case-todo__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.85;
}
.case-article details.case-details {
margin: 0.75rem 0 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-sm, 8px);
}
.case-article details.case-details > summary {
cursor: pointer;
font-weight: 600;
color: var(--accent);
padding: 0.25rem 0;
}
.case-article details.case-details[open] > summary {
margin-bottom: 0.5rem;
}
.case-block {
margin-top: 3rem;
padding-top: 2.5rem;
border-top: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.case-block:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
</style>
<script>
(function () {
@@ -176,131 +267,432 @@
</script>
</head>
<body data-current-case="card-columns">
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../index.html" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian · Plugin</p>
<h1 class="case-page__h1">Card Columns</h1>
<p class="case-page__sub">Плагин для колоночного отображения карточек в Obsidian</p>
<p class="section__prose">
<!-- Здесь будет введение -->
<p class="hero__eyebrow">Obsidian Bases · Plugin</p>
<h1 class="case-page__h1">
Канбан-вид для Obsidian Bases,<br />который не&nbsp;трогает ваши заметки
</h1>
<p class="case-page__sub">
Card Columns: заметки как карточки по колонкам,<br />
отбор по тегам И/ИЛИ за два клика
</p>
<h2 id="context">Контекст</h2>
<!-- МЕДИА: hero-спойлер, финальный экран плагина. Раскомментировать при вставке скрина.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/hero.webp" alt="Card Columns: колонки карточек поверх .base файла" width="1920" height="1080" loading="eager" decoding="async" data-image-viewer />
<figcaption>Вид Columns поверх базы заметок</figcaption>
</figure>
-->
<ul class="case-brief">
<li><span>Роль</span>Продуктовый дизайн и разработка</li>
<li><span>Платформа</span>Obsidian Bases</li>
<li><span>Статус</span>Рабочий плагин, open source (MIT)</li>
<li><span>Команда</span>Соло, пет-проект</li>
<li><span>Срок</span>Черновой ответ, заменить</li>
</ul>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных?
</p>
<p>
Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу
собственной работы. Заменить на реальное.
</p>
</div>
<p class="section__prose">
<!-- Контекст проекта -->
В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а
в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он
плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их
раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns.
</p>
<h2 id="task">Задача</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<div class="case-block" id="kontekst">
<h2>Контекст</h2>
<p class="section__prose">
Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь
заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть
встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет
нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель
Bases наравне со штатными.
</p>
<p class="section__prose">
Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что
выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика
выборки остаётся на Bases, а моя задача, только представление и навигация по нему.
</p>
<!-- МЕДИА: общий скрин/видео обзора — доска колонок поверх .base файла.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/overview.webp" alt="Вид Card Columns поверх .base файла: заметки по колонкам" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Обзор вида</figcaption>
</figure>
-->
</div>
<h2 id="solution">Решение</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<div class="case-block" id="pochemu-svoi">
<h2>Почему не готовое</h2>
<p class="section__prose">
Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо:
</p>
<p class="section__prose">
<strong>Штатный вид карточек Bases.</strong> Рисует ровный поток карточек без группировки.
Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки.
Отпал первым.
</p>
<p class="section__prose">
<strong>Канбан-плагины из комьюнити.</strong> Большинство тянет за собой свою систему
хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база,
которую надо синхронизировать. Мне это было не нужно.
</p>
<p class="section__prose">
<strong>Соседний bases-kanban.</strong> Ближайший по духу: цепляется к Bases, делает
канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина
это риск, о котором ниже.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
Отказ от drag-and-drop, это не недоделка, а позиция. Плагин представления не должен
писать в пользовательские данные. Это решение определило всё остальное.
</p>
</div>
</aside>
<h2 id="result">Результат</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие
именно смотрел?
</p>
<p>
Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban
смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать.
</p>
</div>
</div>
<div class="case-block" id="zadacha">
<h2>Задача</h2>
<p class="section__prose">
Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет
быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит
своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны
работать как раньше.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
Главный риск, сделать вид, который конфликтует со штатным тулбаром Bases. Тогда
пользователь теряет сортировку и поиск, ради которых Bases и берут. Этого нельзя
было допустить.
</p>
</div>
</aside>
</div>
<div class="case-block" id="reshenie">
<h2>Решения</h2>
<p class="section__prose">
Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса,
группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов,
которые определили результат. В каждом есть то, от чего я отказался.
</p>
<!-- МЕДИА: скриншот вида с колонками и карточками внутри.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/board.webp" alt="Колонки карточек с группировкой по свойству" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Вид с колонками</figcaption>
</figure>
-->
<h3>Группировка по любому свойству</h3>
<p class="section__prose">
Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому
frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна
и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по
приоритету.
</p>
<h3>Фильтр тегов по И и ИЛИ</h3>
<p class="section__prose">
Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они
отсеивают карточки по всем колонкам сразу:
</p>
<ul class="section__prose">
<li>Левый клик по тегу, показать только его (сбрасывает остальные фильтры).</li>
<li>Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).</li>
<li>Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.</li>
<li>Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.</li>
<li>All, снять все фильтры.</li>
</ul>
<p class="section__prose">
Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной:
быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку,
отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано.
</p>
<p class="section__prose">
Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо
отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы
с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил.
Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда.
</p>
<!-- МЕДИА: короткое видео фильтрации по тегам (OR / AND).
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../assets/card-columns-obsidian/filter-poster.webp" data-case-video-player style="display: block; width: 100%; height: auto; margin: 0">
<source src="../assets/card-columns-obsidian/filter.webm" type="video/webm" />
<source src="../assets/card-columns-obsidian/filter.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
</div>
<figcaption>Фильтрация по тегам: OR и AND</figcaption>
</figure>
-->
<h3>Карточки и обложки</h3>
<p class="section__prose">
По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно
показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки
полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание
(cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего).
</p>
<h3>Решение: настройки вынесены из кода</h3>
<p class="section__prose">
Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать
для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид
подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров.
</p>
<details class="case-details">
<summary>Показать полный список настроек</summary>
<p class="section__prose"><strong>General</strong></p>
<ul class="section__prose">
<li>Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.</li>
<li>Ширина карточки (px).</li>
<li>Колонок в группе (1–6), сетка внутри колонки.</li>
<li>Зебра колонок (чередование фона для сканирования).</li>
<li>Masonry-раскладка (карточки заполняют вертикальные пустоты).</li>
</ul>
<p class="section__prose"><strong>Title</strong></p>
<ul class="section__prose">
<li>Перенос заголовка, жирный заголовок, размер шрифта (px).</li>
</ul>
<p class="section__prose"><strong>Properties</strong></p>
<ul class="section__prose">
<li>Раскладка чипов: стек (вертикально) или сетка (компактно в строку).</li>
<li>Перенос многострочных значений, размер шрифта.</li>
<li>Формат даты и даты-времени + локаль (Moment.js).</li>
</ul>
<p class="section__prose"><strong>Cover</strong></p>
<ul class="section__prose">
<li>Источник: нет / первая картинка в заметке / свойство-обложка.</li>
<li>Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.</li>
</ul>
</details>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса
в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно.
</p>
<p>
Черновой ответ: да, первая версия была под мою базу, настройки появились, когда
понадобилось открыть плагин наружу. Проверить и поправить.
</p>
</div>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
Сплит-превью сделал как в самом Obsidian: левый клик переиспользует панель, Ctrl+Клик,
новую вкладку внутри сплита. Без этого на широком экране карточки неудобно
просматривать рядом с доской.
</p>
</div>
</aside>
<h3>Работает внутри штатного тулбара</h3>
<p class="section__prose">
Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из
тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не
дублируются и не зависят от отдельного хранилища.
</p>
<!-- МЕДИА: скриншот настроек (gear-меню) с секциями.
<figure class="case-media case-media--content">
<img src="../assets/card-columns-obsidian/settings.webp" alt="Окно настроек плагина: General, Title, Properties, Cover" width="1200" height="675" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Настройки вида</figcaption>
</figure>
-->
</div>
<div class="case-block" id="rezultat">
<h2>Результат и как я это мерял</h2>
<p class="section__prose">
Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента
карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному
виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не
ломают.
</p>
<h3>Замер: сколько действий на отбор</h3>
<p class="section__prose">
Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную
карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В
Columns, два клика по пилюлям и колонка перед глазами.
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный
вид против Columns. Или посчитать точное число действий.
</p>
<p>
Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и
требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные,
заменить на замеренные.
</p>
</div>
<h3>Замер: живучесть в ежедневной работе</h3>
<p class="section__prose">
Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я
пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует
вспоминать, как им управлять.
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая
цифра, подтверждающая, что плагином пользуются не только ты.
</p>
<p>
Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так
что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда,
это сильнее любого-self-report.
</p>
</div>
<p class="section__prose">
Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно,
это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных.
Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А
пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что
мешает им пользоваться сходу.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
Главный вывод: вид для Bases получается рабочим только тогда, когда он уважает
тулбар фреймворка. Я добавил представление, а не вторую систему хранения, и заметки
остались обычными заметками.
</p>
</div>
</aside>
</div>
</div>
</main>
@@ -308,7 +700,7 @@
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="ai-assistant">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
@@ -323,7 +715,7 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="card-columns">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
@@ -338,7 +730,7 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="site-with-ai-ide">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
@@ -357,44 +749,59 @@
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Контакты</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Яндекс</span><span class="contact-card__hint">Да, тут тоже можно</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Контакты</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section class="section section--surface" aria-hidden="true">
<div class="section__inner"></div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true"> · </span><nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span>
</div>
</footer>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../../cookie-policy/" data-cookie-policy-link>политике cookie</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+241 -226
View File
@@ -8,101 +8,101 @@
content="Кейс: Этот сайт — разработка с использованием AI IDE."
/>
<title>Этот сайт — Разработка с AI IDE — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -176,101 +176,101 @@
</script>
</head>
<body data-current-case="site-with-ai-ide">
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../index.html" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
@@ -308,7 +308,7 @@
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="ai-assistant">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
@@ -323,7 +323,7 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="card-columns">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
@@ -338,7 +338,7 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-related-slug="site-with-ai-ide">
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
@@ -357,44 +357,59 @@
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Контакты</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Яндекс</span><span class="contact-card__hint">Да, тут тоже можно</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Контакты</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section class="section section--surface" aria-hidden="true">
<div class="section__inner"></div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true"> · </span><nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span>
</div>
</footer>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../../cookie-policy/" data-cookie-policy-link>политике cookie</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+363 -344
View File
@@ -1,344 +1,363 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Как на сайте-портфолио Николая Мамадаева используются cookie, localStorage и связанные технологии браузера."
/>
<title>Политика cookie — Николай Мамадаев</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "../assets/fonts/merriweather.css",
geologica: "../assets/fonts/geologica.css",
mono: "../assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../assets/favicon/site.webmanifest" />
<link rel="preload" href="../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../css/main.css" as="style" />
<link rel="preload" href="../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../js/main.js" />
<link rel="stylesheet" href="../css/main.css" />
<script src="../js/skill-connectors.js" defer></script>
<script type="module" src="../js/main.js"></script>
<style>
.cookie-policy-doc {
max-width: 48rem;
margin: 0 auto;
}
.cookie-policy-doc h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.cookie-policy-doc .section__prose + .section__prose {
margin-top: 0.75rem;
}
.cookie-policy-doc table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
font-size: 0.92rem;
color: var(--text-muted);
}
.cookie-policy-doc th,
.cookie-policy-doc td {
border: 1px solid var(--border);
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.cookie-policy-doc th {
color: var(--text);
font-weight: 600;
background: color-mix(in srgb, var(--surface-elevated) 80%, transparent);
}
.cookie-policy-doc code {
font-family: var(--font);
font-size: 0.88em;
color: var(--text);
}
.cookie-policy-doc .section__prose a {
color: var(--accent);
}
</style>
</head>
<body>
<a class="skip-link" href="#main">К основному содержимому</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../index.html" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<a href="../index.html">На главную</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="../index.html#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="main" class="section section--tight">
<div class="section__inner cookie-policy-doc">
<p class="hero__eyebrow">Юридическая информация</p>
<h1 class="case-page__h1">Политика использования файлов cookie и локального хранилища</h1>
<p class="section__prose"><strong>Дата последнего обновления:</strong> 9 мая 2026 г.</p>
<p class="section__prose">
Настоящий документ описывает, как на личном сайте-портфолио Николая Мамадаева (далее — «сайт») используются технологии хранения данных в браузере.
</p>
<h2 id="operator">Кто отвечает за сайт</h2>
<p class="section__prose">
Оператором сайта является <strong>Николай Мамадаев</strong>. По вопросам обработки данных используйте контакты в блоке «Контакты» на
<a href="../index.html#contact">главной странице</a>
(Telegram, ВКонтакте, Яндекс и почта через копирование адреса кнопкой).
</p>
<h2 id="http-cookie">Используются ли HTTP cookie</h2>
<p class="section__prose">
В текущей версии сайта <strong>сторонние рекламные или аналитические HTTP cookie не подключаются</strong>. В разметке страниц нет счётчиков аналитики (например, Яндекс.Метрики или Google Analytics), которые обычно устанавливают собственные cookie.
</p>
<p class="section__prose">
При размещении сайта на хостинге или за прокси технические записи (например, журналы сервера с IP-адресом и временем запроса) могут обрабатываться хостинг-провайдером по его документам — это не настраивается кодом данного сайта.
</p>
<h2 id="local-storage">Local Storage и аналогичные технологии браузера</h2>
<p class="section__prose">
Для удобства работы сайт сохраняет на вашем устройстве небольшие объёмы данных через <strong>механизм localStorage</strong> в браузере (это не HTTP cookie, но по смыслу близко к ним и также относится к хранению на стороне клиента).
</p>
<p class="section__prose">Используются следующие ключи:</p>
<table>
<thead>
<tr>
<th scope="col">Ключ</th>
<th scope="col">Назначение</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>siteThemeV1</code></td>
<td>
Настройки оформления: тема (светлая/тёмная/системная), оттенок, скругления, шрифт, фон и связанные параметры панели темы.
</td>
</tr>
<tr>
<td><code>siteCookieConsentV1</code></td>
<td>Значение <code>accepted</code>, если вы нажали «Принять» в уведомлении о cookie; после этого баннер скрывается.</td>
</tr>
</tbody>
</table>
<p class="section__prose">
Эти данные <strong>не передаются на сервер сайта</strong> автоматически: они остаются только в вашем браузере.
</p>
<h2 id="retention">Срок хранения</h2>
<p class="section__prose">
Данные в localStorage сохраняются до тех пор, пока вы <strong>не удалите их вручную</strong> (очистка данных сайта / полное удаление данных браузера) или пока браузер не сбросит хранилище по своим правилам.
</p>
<h2 id="consent">Согласие и отказ</h2>
<p class="section__prose">
Нажимая «Принять» в баннере, вы подтверждаете, что ознакомились с настоящей политикой. Если вы не нажимаете «Принять», баннер остаётся доступным; функциональность сайта при этом по возможности сохраняется, но запись о согласии не создаётся.
</p>
<p class="section__prose">
Вы можете в любой момент <strong>очистить localStorage</strong> для этого сайта в настройках браузера — при этом сбросятся сохранённые тема и отметка о принятии уведомления, и баннер может снова отобразиться.
</p>
<h2 id="changes">Изменения</h2>
<p class="section__prose">
Текст политики может обновляться при изменении технологий на сайте. Актуальная версия публикуется на странице «Политика cookie» по адресу <a href="../cookie-policy/">/cookie-policy.html</a> относительно корня сайта.
</p>
</div>
</main>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../cookie-policy/">Политика cookie</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../cookie-policy/" data-cookie-policy-link>политике cookie</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
</body>
</html>
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Как на сайте-портфолио Николая Мамадаева используются cookie, localStorage и связанные технологии браузера."
/>
<title>Политика cookie — Николай Мамадаев</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.cookie-policy-doc {
max-width: 48rem;
margin: 0 auto;
}
.cookie-policy-doc h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.cookie-policy-doc .section__prose + .section__prose {
margin-top: 0.75rem;
}
.cookie-policy-doc table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
font-size: 0.92rem;
color: var(--text-muted);
}
.cookie-policy-doc th,
.cookie-policy-doc td {
border: 1px solid var(--border);
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.cookie-policy-doc th {
color: var(--text);
font-weight: 600;
background: color-mix(in srgb, var(--surface-elevated) 80%, transparent);
}
.cookie-policy-doc code {
font-family: var(--font);
font-size: 0.88em;
color: var(--text);
}
.cookie-policy-doc .section__prose a {
color: var(--accent);
}
</style>
</head>
<body>
<a class="skip-link" href="#main">К основному содержимому</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы">
<a href="../">На главную</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="../#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="main" class="section section--tight">
<div class="section__inner cookie-policy-doc">
<p class="hero__eyebrow">Юридическая информация</p>
<h1 class="case-page__h1">Политика использования файлов cookie и локального хранилища</h1>
<p class="section__prose"><strong>Дата последнего обновления:</strong> 9 мая 2026 г.</p>
<p class="section__prose">
Настоящий документ описывает, как на личном сайте-портфолио Николая Мамадаева (далее — «сайт») используются технологии хранения данных в браузере.
</p>
<h2 id="operator">Кто отвечает за сайт</h2>
<p class="section__prose">
Оператором сайта является <strong>Николай Мамадаев</strong>. По вопросам обработки данных используйте контакты в блоке «Контакты» на
<a href="../#contact">главной странице</a>
(Telegram, ВКонтакте и почта через копировение адреса кнопкой).
</p>
<h2 id="http-cookie">Используются ли HTTP cookie</h2>
<p class="section__prose">
В текущей версии сайта <strong>сторонние рекламные или аналитические HTTP cookie не подключаются</strong>. В разметке страниц нет счётчиков аналитики (например, Яндекс.Метрики или Google Analytics), которые обычно устанавливают собственные cookie.
</p>
<p class="section__prose">
При размещении сайта на хостинге или за прокси технические записи (например, журналы сервера с IP-адресом и временем запроса) могут обрабатываться хостинг-провайдером по его документам — это не настраивается кодом данного сайта.
</p>
<h2 id="local-storage">Local Storage и аналогичные технологии браузера</h2>
<p class="section__prose">
Для удобства работы сайт сохраняет на вашем устройстве небольшие объёмы данных через <strong>механизм localStorage</strong> в браузере (это не HTTP cookie, но по смыслу близко к ним и также относится к хранению на стороне клиента).
</p>
<p class="section__prose">Используются следующие ключи:</p>
<table>
<thead>
<tr>
<th scope="col">Ключ</th>
<th scope="col">Назначение</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>siteThemeV1</code></td>
<td>
Настройки оформления: тема (светлая/тёмная/системная), оттенок, скругления, шрифт, фон и связанные параметры панели темы.
</td>
</tr>
<tr>
<td><code>siteCookieConsentV1</code></td>
<td>Значение <code>accepted</code>, если вы нажали «Принять» в уведомлении о cookie; после этого баннер скрывается.</td>
</tr>
</tbody>
</table>
<p class="section__prose">
Эти данные <strong>не передаются на сервер сайта</strong> автоматически: они остаются только в вашем браузере.
</p>
<h2 id="retention">Срок хранения</h2>
<p class="section__prose">
Данные в localStorage сохраняются до тех пор, пока вы <strong>не удалите их вручную</strong> (очистка данных сайта / полное удаление данных браузера) или пока браузер не сбросит хранилище по своим правилам.
</p>
<h2 id="consent">Согласие и отказ</h2>
<p class="section__prose">
Нажимая «Принять» в баннере, вы подтверждаете, что ознакомились с настоящей политикой. Если вы не нажимаете «Принять», баннер остаётся доступным; функциональность сайта при этом по возможности сохраняется, но запись о согласии не создаётся.
</p>
<p class="section__prose">
Вы можете в любой момент <strong>очистить localStorage</strong> для этого сайта в настройках браузера — при этом сбросятся сохранённые тема и отметка о принятии уведомления, и баннер может снова отобразиться.
</p>
<h2 id="changes">Изменения</h2>
<p class="section__prose">
Текст политики может обновляться при изменении технологий на сайте. Актуальная версия публикуется на странице «Политика cookie» по адресу <a href="../cookie-policy/">/cookie-policy.html</a> относительно корня сайта.
</p>
</div>
</main>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true"> · </span><nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
</body>
</html>
+746 -120
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,423 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: AI Assistant—a Figma plugin with AI features."
/>
<title>AI Assistant — Figma Plugin — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="ai-assistant">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Figma · Plugin</p>
<h1 class="case-page__h1">AI Assistant</h1>
<p class="case-page__sub">A Figma plugin with AI features</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Context</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Task</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Solution</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h2 id="result">Result</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI Assistant — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI Assistant</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Figma plugin</li>
</ul>
<p class="mp-card__desc">A Figma plugin with AI features.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Obsidian Plugin</li>
</ul>
<p class="mp-card__desc">A plugin for displaying cards in columns in Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="This website — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">This website</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Development with AI IDE</li>
</ul>
<p class="mp-card__desc">Website development using AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,819 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: Card Columns, a view plugin for Obsidian Bases. It displays notes as cards arranged in columns, with tag filtering using AND/OR."
/>
<title>Card Columns, a plugin for Obsidian Bases: a case study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-article .case-brief {
list-style: none;
margin: 1.5rem 0 0;
padding: 1.1rem 1.25rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.85rem 1.5rem;
font-size: 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-md, 12px);
}
.case-article .case-brief li {
margin: 0;
}
.case-article .case-brief span {
display: block;
margin-bottom: 0.15rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.case-article .case-todo {
margin: 1rem 0 1.25rem;
padding: 0.9rem 1.1rem;
border-left: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 var(--radius-sm, 8px) var(--radius-sm, 8px) 0;
font-size: 0.92rem;
}
.case-article .case-todo p {
margin: 0 0 0.5rem;
}
.case-article .case-todo p:last-child {
margin-bottom: 0;
}
.case-article .case-todo__q {
font-weight: 600;
color: var(--accent);
}
.case-todo__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.85;
}
.case-article details.case-details {
margin: 0.75rem 0 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-sm, 8px);
}
.case-article details.case-details > summary {
cursor: pointer;
font-weight: 600;
color: var(--accent);
padding: 0.25rem 0;
}
.case-article details.case-details[open] > summary {
margin-bottom: 0.5rem;
}
.case-block {
margin-top: 3rem;
padding-top: 2.5rem;
border-top: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.case-block:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="card-columns">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian Bases · Plugin</p>
<h1 class="case-page__h1">
A Kanban view for Obsidian Bases<br />
that leaves your notes untouched
</h1>
<p class="case-page__sub">
Card Columns: Notes as cards in columns,<br />
filtered by AND/OR tags with just two clicks
</p>
<!-- МЕДИА: hero-спойлер, финальный экран плагина. Раскомментировать при вставке скрина.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/hero.webp" alt="Card Columns: card columns on top of the .base file" width="1920" height="1080" loading="eager" decoding="async" data-image-viewer />
<figcaption>"Columns" view on top of the note database</figcaption>
</figure>
-->
<ul class="case-brief">
<li>Role: Product Design and Development</li>
<li><span>Platform:</span> Obsidian Bases</li>
<li><span>Status</span>: Working plugin, open source (MIT)</li>
<li><span>Team: Solo</span>, side project</li>
<li><span>Status:</span> Draft response, to be replaced</li>
</ul>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
How long did it take from the initial idea to a working version? Weeks? A month of weekends?
</p>
<p>
Draft answer: I put it together in three weeks in the evenings, then spent another month refining it while
working on my own projects. Replace with the actual time.
</p>
</div>
<p class="section__prose">
Obsidian now has Bases, a separate file type that displays notes not as text, but
in various formats: tables, cards, and trees. The standard card view didn’t work for me: it’s
flat, with no grouping, and it’s hard to make sense of when there are hundreds of notes. I wanted to see them
arranged in columns, like a Kanban board, but without the extra overhead. That’s how the Card Columns view plugin came about.
</p>
<div class="case-block" id="kontekst">
<h2>Context</h2>
<p class="section__prose">
A “Bases” is neither a note nor a separate database. It’s a query to the vault: you select
notes using a filter, and the framework displays them in the chosen view. Obsidian itself has
a built-in card view, but it’s one-dimensional: no grouping by properties, no
proper filtering. I decided to create my own view that integrates into the
Bases toggle alongside the default ones.
</p>
<p class="section__prose">
The main limitation was in the framework itself: the plugin doesn’t store data, but rather renders what
the Bases query returns (Sort, Filter, Properties, Search from the toolbar). This means that all the logic
for data retrieval remains with Bases, and my task is solely to display and navigate the results.
</p>
<!-- МЕДИА: общий скрин/видео обзора — доска колонок поверх .base файла.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/overview.webp" alt="View of "Card Columns" in the .base file: notes on the columns" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Species Overview</figcaption>
</figure>
-->
</div>
<div class="case-block" id="pochemu-svoi">
<h2>Why not use a ready-made solution?</h2>
<p class="section__prose">
Before writing any code, I checked what was already available. Three options, none of them worked:
</p>
<p class="section__prose">
<strong>The default Bases card view.</strong>
It displays a continuous stream of cards without grouping.
When there are over a hundred notes, it becomes a feed where you have to scroll and squint at the titles.
I ruled that out first.
</p>
<p class="section__prose">
<strong>Kanban plugins from the community.</strong>
Most come with their own storage system:
the board exists separately from the vault, and notes are duplicated. This results in a second database
that needs to be synchronized. I didn’t need that.
</p>
<p class="section__prose">
<strong>The neighboring bases-kanban.</strong>
The closest in spirit: it hooks into Bases to create
a Kanban board. But it writes to the notes: dragging a card changes the frontmatter. For a view plugin,
this is a risk, which I’ll discuss below.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
The decision to forgo drag-and-drop isn’t a shortcoming—it’s a design choice. A view plugin should not
write to user data. This decision shaped everything else.
</p>
</div>
</aside>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Did you actually install and test other people’s plugins, or did you just go by the descriptions? Which
ones did you look at specifically?
</p>
<p>
Preliminary answer: I installed three of them, ran them for a week on my database, then uninstalled them. I looked at the source code for bases-kanban
but didn’t install it. Replace with actual information; include the plugin names.
</p>
</div>
</div>
<div class="case-block" id="zadacha">
<h2>Task</h2>
<p class="section__prose">
Design a view for Bases that displays notes as cards arranged in columns and allows you to
quickly select the ones you need. Strict constraint: the plugin does not write anything to the notes and does not store
its own state, and sorting, filtering, properties, and search from the standard Bases toolbar must
work as before.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
The main risk is creating a view that conflicts with the standard Bases toolbar. If that happens,
the user loses the sorting and search capabilities—the very features that make Bases worth using. This could not
be allowed to happen.
</p>
</div>
</aside>
</div>
<div class="case-block" id="reshenie">
<h2>Solutions</h2>
<p class="section__prose">
The plugin adds a “Columns” view to Bases. Cards are built from the results of a Bases query;
grouping is defined by a property, and filtering is done via clickable tags. Next, there are five options
that determine the result. Each one includes something I decided against.
</p>
<!-- МЕДИА: скриншот вида с колонками и карточками внутри.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/board.webp" alt="Columns of cards grouped by property" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>View with columns</figcaption>
</figure>
-->
<h3>Grouping by any property</h3>
<p class="section__prose">
Columns are generated using the “Group by” option in the Bases toolbar, by tag, status, category, or any
frontmatter property. Change the property in the query, and the board reloads automatically. This way, a single
database covers different perspectives: projects by status, notes by tags, tasks by
priority.
</p>
<h3>Tag filter by AND and OR</h3>
<p class="section__prose">
This is exactly what the plugin was designed for. Below the toolbar, there are tag icons; when clicked, they
filter cards across all columns at once:
</p>
<ul class="section__prose">
<li>Left-click on a tag to show only that tag (resets the other filters).</li>
<li>Left-click on the next tag to show notes with ANY of the selected tags (OR mode).</li>
<li>Ctrl+Click or right-click to switch tags without resetting the selection.</li>
<li>The OR/AND button: toggle between “any of” and “all of” for multiple tags.</li>
<li>All: Clear all filters.</li>
</ul>
<p class="section__prose">
The combination of “OR—add with a left-click + exact toggle via Ctrl” turned out to be intuitive:
quick selection doesn’t disrupt what’s already selected. I immediately rejected the alternative—moving the filter to a modal window—
because it requires an extra click and loses the context of what’s already been filtered.
</p>
<p class="section__prose">
The choice here isn’t about the feature itself, but where it lives. I chose the pills below the toolbar instead of
a separate filter window because filtering by tags is an action performed at every step of working
with the database. A modal window turns it into an interrupted workflow: open, configure, close, check.
The pill-shaped icons keep the result right in front of you, and the filter status is always visible.
</p>
<!-- МЕДИА: короткое видео фильтрации по тегам (OR / AND).
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../assets/card-columns-obsidian/filter-poster.webp" data-case-video-player style="display: block; width: 100%; height: auto; margin: 0">
<source src="../assets/card-columns-obsidian/filter.webm" type="video/webm" />
<source src="../assets/card-columns-obsidian/filter.mp4" type="video/mp4" />
Your browser does not support video.
</video>
</div>
<figcaption>Filtering by tags: OR and AND</figcaption>
</figure>
-->
<h3>Cards and Covers</h3>
<p class="section__prose">
Properties such as chips, text, tags, links, Booleans, and dates are displayed for each note. You can
show a cover: from the first image in the note or from a separate property. You have
full control over the cover, including aspect ratio (Auto, 1:1, 3:2, 4:3, 16:9), border, fit
(cover/contain), orientation, and position on the card (above the title, below it, at the end).
</p>
<h3>Solution: Settings have been moved out of the code</h3>
<p class="section__prose">
At first, the parameters were hardcoded for my database. This was a mistake: the view would stop working
for someone else’s scenario. I moved everything to the gear menu, dividing it into four sections so that the view
could adapt to different databases, rather than just my single use case. More than 15 parameters.
</p>
<details class="case-details">
<summary>Show the full list of settings</summary>
<p class="section__prose"><strong>General</strong></p>
<ul class="section__prose">
<li>Where to open the card: active panel, pop-up window, new tab, split to the right / down.</li>
<li>Card width (px).</li>
<li>Columns per group (1–6), grid within a column.</li>
<li>Zebra columns (alternating background colors for easy scanning).</li>
<li>Masonry layout (cards fill vertical gaps).</li>
</ul>
<p class="section__prose"><strong>Title</strong></p>
<ul class="section__prose">
<li>Title wrapping, bold title, font size (px).</li>
</ul>
<p class="section__prose"><strong>Properties</strong></p>
<ul class="section__prose">
<li>Chip layout: stack (vertical) or grid (compact in a row).</li>
<li>Wrap multi-line values, font size.</li>
<li>Date and date-time formats + locale (Moment.js).</li>
</ul>
<p class="section__prose"><strong>Cover</strong></p>
<ul class="section__prose">
<li>Source: none / first image in the post / cover property.</li>
<li>Style (borderless / with border), aspect ratio, orientation, fit, position on the card.</li>
</ul>
</details>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Is it true about the hard-coded parameters? If not, replace it with the real reason for moving them
to the settings: user requests, multiple databases with different structures, whatever.
</p>
<p>
Preliminary answer: Yes, the first version was tailored to my database; the settings were added when
I needed to make the plugin available externally. I’ll check and fix it.
</p>
</div>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
I implemented the split preview just like in Obsidian itself: left-click reuses the panel, Ctrl+click opens
a new tab within the split. Without this, on a wide screen, it’s inconvenient to
view cards next to the board.
</p>
</div>
</aside>
<h3>Works within the standard toolbar</h3>
<p class="section__prose">
The view hooks into the BasesView framework, so sorting, filtering, properties, and search from
the Bases toolbar remain functional. The plugin only renders the query results; the data is not
duplicated and does not depend on a separate storage.
</p>
<!-- МЕДИА: скриншот настроек (gear-меню) с секциями.
<figure class="case-media case-media--content">
<img src="../assets/card-columns-obsidian/settings.webp" alt="Plugin Settings Window: General, Title, Properties, Cover" width="1200" height="675" loading="lazy" decoding="async" data-image-viewer />
<figcaption>View Settings</figcaption>
</figure>
-->
</div>
<div class="case-block" id="rezultat">
<h2>The Result and How I Tested It</h2>
<p class="section__prose">
This view solved my problem: a Bases query now reads like a board, rather than a flat stream
of cards. Grouping by property + filtering by AND/OR tags—exactly what the default
view was missing. Everything runs on the Bases query, so moving the vault and syncing don’t
break anything.
</p>
<h3>Metrics: number of steps to filter</h3>
<p class="section__prose">
The main task I used to measure success: filter notes by two tags and find the right
card. In the standard card view, this involves scrolling through the feed and scanning the titles with your eyes. In
Columns, two clicks on the pills and the column is right in front of you.
</p>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Can you run this test for real? Time 10 repetitions of the task: the default
view versus Columns. Or count the exact number of steps.
</p>
<p>
Preliminary answer: on a sample of 25 notes, filtering by two tags took about 40 seconds and
required scrolling through the feed; in Columns, two clicks, about 5 seconds. These are rough estimates—
replace them with actual measurements.
</p>
</div>
<h3>Test: Long-term usability in daily work</h3>
<p class="section__prose">
The second criterion is more subjective, but for a tool tailored to my needs, it’s more important: will I still be
using the plugin a month from now? I am—it’s become an integral part of my work with the database and doesn’t require
me to remember how to use it.
</p>
<div class="case-todo">
<span class="case-todo__label">Question for the author</span>
<p class="case-todo__q">
Are there any external indicators: installs, GitHub stars, reviews, or questions in the issue tracker? Any
number confirming that you’re not the only one using the plugin.
</p>
<p>
Preliminary answer: The plugin currently needs to be installed manually; it hasn’t been approved for the community repository yet, so
there are only a handful of installs and no stars so far. If there’s actual data, include it here—
it’s more convincing than any self-report.
</p>
</div>
<p class="section__prose">
What I’d do differently. I deliberately chose not to implement drag-and-drop of cards between columns,
since that changes a property in the note, and for a view plugin, that poses a separate data integrity risk.
But I see a demand for this among users, and I might add it as an optional mode. For
now, since the plugin is maintained manually (it hasn’t been reviewed by the community directory yet), this is the only thing
preventing users from using it right away.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
Main conclusion: a view for Bases only works when it respects
the framework’s toolbar. I added a view, not a second storage system, and the notes
remained regular notes.
</p>
</div>
</aside>
</div>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI Assistant — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI Assistant</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Figma plugin</li>
</ul>
<p class="mp-card__desc">A Figma plugin with AI features.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Obsidian Plugin</li>
</ul>
<p class="mp-card__desc">A plugin for displaying cards in columns in Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="This website — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">This website</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Development with AI IDE</li>
</ul>
<p class="mp-card__desc">Website development using AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+763
View File
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: Redesign and Development of the MANU Game Engine Interface—Visual System and Complex User Scenarios."
/>
<title>MANU Game Engine — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="manu-game-maker">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Product Design · Desktop App</p>
<h1 class="case-page__h1">MANU Game Engine</h1>
<p class="case-page__sub">
Interface redesign and development: a new operational concept, an updated visual system, and hundreds of carefully crafted user scenarios.
</p>
<p class="section__prose">
The work focused on the desktop editor and associated panels: the goal was to maintain the integrity of the visual language while expanding functionality and ensuring that the usage scenarios of power users were preserved.
</p>
<p class="section__prose">
A detailed case study will be posted on this page later; below is the cover image for portfolio navigation.
</p>
<h2>Cover</h2>
<figure class="case-media case-media--wide">
<img
src="../../../assets/manu-game-maker/cover.webp"
alt="MANU Game Engine Interface — Case Cover"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Current preview for the portfolio card</figcaption>
</figure>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="AI Platform — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Lunacy Image Editor — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="MANU Game Engine — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MANU Game Engine</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Redesign and development of the game engine’s user interface: a new operational concept, an updated visual system, and the development of hundreds of complex user scenarios.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Mobile app for a network of clinics — open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Mobile app for a network of clinics</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Design of a mobile app for scheduling doctor appointments and managing medical services. Focus on a simple user journey, intuitive navigation, and quick access to necessary actions.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Web Panel for Automation Orchestration — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Mentoring and Coaching</li>
</ul>
<p class="mp-card__desc">Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="The concept of a task-assignment service—open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task Distribution Service Concept</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Product concept for a service to create, assign, and track tasks, with an emphasis on process transparency and seamless teamwork.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Task Tracker for the Back-Office System — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task tracker for a back-office system</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for planning and assigning tasks in the operations department: shared pool, personal to-do list, overview by assignee; tabs, filters, board/list/table views, and drag-and-drop functionality.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: A mobile app for a network of clinics—appointment scheduling, medical services, navigation, and quick actions."
/>
<title>Mobile App for a Network of Clinics — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="mobile-clinic-app">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">UX/UI · Mobile App</p>
<h1 class="case-page__h1">Mobile App for a Clinic Network</h1>
<p class="case-page__sub">
Booking doctor appointments and managing medical services: a simple user journey, intuitive navigation, and quick access to actions.
</p>
<p class="section__prose">
The project’s focus is on alleviating patient anxiety and eliminating unnecessary steps: clear appointment statuses, predictable forms, and reliance on familiar mobile app patterns.
</p>
<p class="section__prose">
A full breakdown of the case study will be published here; for now, this page serves as an entry point from the portfolio section on the homepage.
</p>
<h2>Cover</h2>
<figure class="case-media case-media--wide">
<img
src="../../../assets/mobile-clinic-app/cover.webp"
alt="Mobile App for a Network of Clinics — Case Study Cover"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Current preview for the portfolio card</figcaption>
</figure>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="AI Platform — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Lunacy Image Editor — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="MANU Game Engine — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MANU Game Engine</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Redesign and development of the game engine’s user interface: a new operational concept, an updated visual system, and the development of hundreds of complex user scenarios.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Mobile app for a network of clinics — open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Mobile app for a network of clinics</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Design of a mobile app for scheduling doctor appointments and managing medical services. Focus on a simple user journey, intuitive navigation, and quick access to necessary actions.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Web Panel for Automation Orchestration — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Mentoring and Coaching</li>
</ul>
<p class="mp-card__desc">Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="The concept of a task-assignment service—open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task Distribution Service Concept</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Product concept for a service to create, assign, and track tasks, with an emphasis on process transparency and seamless teamwork.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Task Tracker for the Back-Office System — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task tracker for a back-office system</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for planning and assigning tasks in the operations department: shared pool, personal to-do list, overview by assignee; tabs, filters, board/list/table views, and drag-and-drop functionality.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
@@ -0,0 +1,790 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: A Column-Based Plugin for Obsidian — Design and UX of a Note-Taking Plugin."
/>
<title>Column View for Obsidian — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body>
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian · Plugin</p>
<h1 class="case-page__h1">Column View — a plugin for Obsidian</h1>
<p class="case-page__sub">Design and development of an interface for multi-column note management</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Context</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Task</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Solution</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h3>Usage Filter</h3>
<figure class="case-media">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../../../assets/obsidian-plugin-columns-view/filter use.webp" data-case-video-player style="display: block; width: 100%; height: auto">
<source src="../../../assets/obsidian-plugin-columns-view/filter use.webm" type="video/webm" />
<source src="../../../assets/obsidian-plugin-columns-view/filter use.mp4" type="video/mp4" />
Your browser does not support video.
</video>
</div>
<figcaption>Filtering notes in a columnar view.</figcaption>
</figure>
<h3>Display Configuration</h3>
<figure class="case-media">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../../../assets/obsidian-plugin-columns-view/view configure.webp" data-case-video-player style="display: block; width: 100%; height: auto">
<source src="../../../assets/obsidian-plugin-columns-view/view configure.webm" type="video/webm" />
<source src="../../../assets/obsidian-plugin-columns-view/view configure.mp4" type="video/mp4" />
Your browser does not support video.
</video>
</div>
<figcaption>Column and display settings.</figcaption>
</figure>
<h2 id="result">Result</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="AI Platform — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Lunacy Image Editor — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="MANU Game Engine — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MANU Game Engine</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Redesign and development of the game engine’s user interface: a new operational concept, an updated visual system, and the development of hundreds of complex user scenarios.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Mobile app for a network of clinics — open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Mobile app for a network of clinics</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Design of a mobile app for scheduling doctor appointments and managing medical services. Focus on a simple user journey, intuitive navigation, and quick access to necessary actions.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Web Panel for Automation Orchestration — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Mentoring and Coaching</li>
</ul>
<p class="mp-card__desc">Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="The concept of a task-assignment service—open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task Distribution Service Concept</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Product concept for a service to create, assign, and track tasks, with an emphasis on process transparency and seamless teamwork.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Task Tracker for the Back-Office System — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task tracker for a back-office system</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for planning and assigning tasks in the operations department: shared pool, personal to-do list, overview by assignee; tabs, filters, board/list/table views, and drag-and-drop functionality.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
</body>
</html>
@@ -0,0 +1,901 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: Redesign of the Primo RPA Orchestrator Web Dashboard — design oversight, implementation of Ant Design and Storybook, and token synchronization with Figma."
/>
<title>Web Dashboard for Automation Orchestration — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
margin: 1.25rem 0 0.5rem;
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="orc-automation-panel">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Primo RPA · UX/UI · Web App</p>
<h1 class="case-page__h1">Web Dashboard for Automation Orchestration</h1>
<p class="case-page__sub">
Primo RPA Orchestrator: launching robots, managing projects, and monitoring processes in a web dashboard for administrators and automation developers.
</p>
<div class="case-block" id="obzor">
<h2>Context</h2>
<p class="section__prose">
In the Primo RPA stack, the orchestrator manages the scheduling, queue, and statuses of RPA robots on the client’s infrastructure. It can be accessed not only from the development studio but also from a browser: users view projects, restart tasks, and troubleshoot failures.
</p>
<p class="section__prose">
By that point, the product was already in use, but the interface was noticeably outdated: users faced navigation issues, visual clutter in tables, and confusing workflows in some sections. The goal was to refresh the interface’s look, modernize it, and fix the accumulated UX issues.
</p>
</div>
<div class="case-block" id="rol">
<h2>Role and Approach</h2>
<p class="section__prose">
My role in this project was more of a curatorial one—I didn’t do the design myself, but rather structured the process so that the design and development teams could work in sync.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>I studied the product “from scratch”: I analyzed the orchestrator interface, identified user pain points, and formulated objectives for the update.</p>
</div>
</aside>
<p class="section__prose">
At the same time, I was looking for a designer who could take on the main visual design work. Elizaveta was selected—out of more than 150 applications, she was the best fit for the upcoming workload.
</p>
<p class="section__prose">
Over the course of three months, I brought the new team member up to speed on the project details: I assigned tasks, provided feedback on the mockups she created, and shared context on user scenarios. Together with the development team, we planned the order in which to update the service’s design—we revised screens based on priority, incorporating changes into ongoing releases, without “stopping everything and redesigning from scratch.”
</p>
</div>
<div class="case-block" id="tekhnicheskiy-stek">
<h2>Technical Stack and Tools</h2>
<p class="section__prose">
The latest version of <a href="https://ant.design/" target="_blank" rel="noopener noreferrer">Ant Design</a> was chosen as the UI framework. It was already in use on the project, and its latest release introduced <a href="https://ant.design/theme-editor" target="_blank" rel="noopener noreferrer">the Theme Editor</a>—a feature that allowed us to customize the appearance of components using design tokens without manually overriding styles. This provided both designers and developers with a unified language for describing the user interface.
</p>
<p class="section__prose">
<a href="https://storybook.js.org/" target="_blank" rel="noopener noreferrer">Storybook</a> served as the single source of truth and the bridge between design and development. In Storybook, components weren’t just documented—they actually worked in an isolated environment. This allowed designers to see the elements in action and developers to integrate components into the dashboard more quickly.
</p>
</div>
<div class="case-block" id="dizayn-sistema">
<h2>Design System and Synchronization</h2>
<p class="section__prose">
To streamline our workflow, we synchronized tokens from Ant Design with those in Figma—colors, padding, typography, and corner radii were standardized to consistent values. Here’s how it worked: if tokens were updated in Figma, they could be exported as JSON and immediately imported into the code without any issues—instead of having a developer manually search for changes and update each token individually. This approach also allowed us to view the design more systematically.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>The main goal of this close collaboration between design and development was to translate the original vision into the product as accurately as possible without losing sight of the details along the way.</p>
</div>
</aside>
<p class="section__prose">
Components in Figma were built from scratch—based on Ant Design references, but without using pre-made libraries. The decision to avoid pre-made solutions was a conscious one: we needed full customization to fit our workflow and the ability to use new Figma features that weren’t supported in libraries from the Figma community.
</p>
<p class="section__prose">
For Storybook, we selected and configured plugins that were relevant to our tasks—accessibility checks, viewing component states, and switching themes and locales. This turned Storybook into more than just a showcase for components; it became a full-fledged design review tool.
</p>
</div>
<div class="case-block" id="rabochaya-sreda">
<h2>Designer’s Work Environment</h2>
<p class="section__prose">
We set up the environment so that designers could see the live implementation of components on their own computers using a local Storybook instance and Git. This allowed for more precise control over how the intended design was translated into code and enabled us to quickly spot discrepancies early on.
</p>
<p class="section__prose">
We also discussed a step to accelerate Storybook support by allowing designers to write the JSX for implemented components themselves—this shortened the path from mockup to a documented component in Storybook.
</p>
<p class="section__prose">
We fine-tuned the workflow for adding icons to the library. The key feature was that icons could be displayed in Storybook with filtering and sorting capabilities. At the same time, designers could update icons without involving a developer—they simply pushed new files to the repository.
</p>
<p class="section__prose">
Being able to see the updated icon immediately in the browser allowed us to optimize it better and eliminate display glitches when using Boolean operations.
</p>
<p class="section__prose">
We also developed an approach and set of rules for Figma that not only guided the designer but also ensured the creation of consistent and functional icons.
</p>
</div>
<div class="case-block" id="ux-uluchsheniya">
<h2>UX Improvements</h2>
<p class="section__prose">
During the interface update, Elizaveta also proposed a number of user experience improvements. Some of them were successfully implemented—for example, clearer grouping of elements in complex forms and redesigned loading states. However, the priority was not to disrupt the existing UX that users had grown accustomed to, so many ideas were saved for the backlog for the next iteration.
</p>
</div>
<div class="case-block" id="proekty">
<h2>Project List</h2>
<p class="section__prose">
The project table is one of the most frequently viewed screens. In the old version, there were many columns, a dense grid, and small text: the project status and name were difficult to read, especially on a laptop.
</p>
<p class="section__prose">In the new version:</p>
<ul>
<li>the columns have been reduced to what’s necessary for daily work;</li>
<li>statuses and actions have been moved to predictable areas within each row;</li>
<li>indentation and typography have been aligned with the rest of the panel using Ant Design.</li>
</ul>
<h3>It used to be</h3>
<figure class="case-media case-media--wide">
<img
src="../../../assets/orc-primo-rpa/projects--table-old.webp"
alt="List of projects in the orchestrator — previous version of the table"
width="1919"
height="867"
loading="lazy"
decoding="async"
data-image-viewer
/>
</figure>
<h3>Now</h3>
<figure class="case-media case-media--wide">
<img
src="../../../assets/orc-primo-rpa/projects--table-new.webp"
alt="List of Projects in the Orchestrator — Updated Table"
width="1366"
height="768"
loading="lazy"
decoding="async"
data-image-viewer
/>
</figure>
</div>
<div class="case-block" id="itog">
<h2>Results and Reflection</h2>
<p class="section__prose">
The projects screen became easier to scan: less visual noise, and it’s faster to find the right project and its status. After rolling out the new design to several clients, we received positive feedback and many ideas for further improvements.
</p>
<p class="section__prose">
I stepped away from the project once Elizaveta felt confident in her role and was able to handle design tasks independently. I occasionally answered specific questions, but overall, the handoff went smoothly.
</p>
<p class="section__prose">
A little over a year later, the team’s processes have changed significantly. According to the team, tools powered by neural networks—such as Claude Design—are now actively involved in the creation and handoff of designs. What we used to build manually using tokens, Storybook, and environment synchronization is now, to some extent, automated.
</p>
<p class="section__prose">
I believe the work was completed fairly quickly and to a high standard—amid constant changes in the market and within the company, we managed not only to update the interface but also to establish a sustainable process that continued to function even without my involvement.
</p>
</div>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="AI Platform — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Lunacy Image Editor — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="MANU Game Engine — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MANU Game Engine</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Redesign and development of the game engine’s user interface: a new operational concept, an updated visual system, and the development of hundreds of complex user scenarios.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Mobile app for a network of clinics — open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Mobile app for a network of clinics</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Design of a mobile app for scheduling doctor appointments and managing medical services. Focus on a simple user journey, intuitive navigation, and quick access to necessary actions.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Web Panel for Automation Orchestration — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Mentoring and Coaching</li>
</ul>
<p class="mp-card__desc">Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="The concept of a task-assignment service—open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task Distribution Service Concept</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Product concept for a service to create, assign, and track tasks, with an emphasis on process transparency and seamless teamwork.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Task Tracker for the Back-Office System — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task tracker for a back-office system</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for planning and assigning tasks in the operations department: shared pool, personal to-do list, overview by assignee; tabs, filters, board/list/table views, and drag-and-drop functionality.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
+423
View File
@@ -0,0 +1,423 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: This website was developed using an AI IDE."
/>
<title>This Website — Developed with AI IDE — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="site-with-ai-ide">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Development · AI IDE</p>
<h1 class="case-page__h1">This website</h1>
<p class="case-page__sub">Website Development Using AI IDE</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Context</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Task</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Solution</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h2 id="result">Result</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI Assistant — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI Assistant</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Figma plugin</li>
</ul>
<p class="mp-card__desc">A Figma plugin with AI features.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Obsidian Plugin</li>
</ul>
<p class="mp-card__desc">A plugin for displaying cards in columns in Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="This website — Open a Case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">This website</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Development with AI IDE</li>
</ul>
<p class="mp-card__desc">Website development using AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: A Task-Assignment Service Concept—Transparency in Processes and Teamwork."
/>
<title>Task Distribution Service Concept — Case Study</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="task-distribution-concept">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">UX/UI · Analytics</p>
<h1 class="case-page__h1">Task Assignment Service Concept</h1>
<p class="case-page__sub">
Product concept: setting, assigning, and tracking tasks with an emphasis on process transparency and seamless teamwork.
</p>
<p class="section__prose">
The goal of the concept is to show how the team views the workflow without “black boxes”: who is working on what, where the bottlenecks are, and which statuses are critical for the next step.
</p>
<p class="section__prose">
A detailed case study will be posted here later; the page is already linked to a portfolio entry on the website.
</p>
<h2>Cover</h2>
<figure class="case-media case-media--wide">
<img
src="../../../assets/task-distribution-concept/cover.webp"
alt="Task Distribution Service Concept — Case Cover"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Current preview for the portfolio card</figcaption>
</figure>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">See also</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="AI Platform — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Lunacy Image Editor — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="MANU Game Engine — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MANU Game Engine</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Redesign and development of the game engine’s user interface: a new operational concept, an updated visual system, and the development of hundreds of complex user scenarios.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Mobile app for a network of clinics — open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Mobile app for a network of clinics</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Design of a mobile app for scheduling doctor appointments and managing medical services. Focus on a simple user journey, intuitive navigation, and quick access to necessary actions.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Web Panel for Automation Orchestration — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Mentoring and Coaching</li>
</ul>
<p class="mp-card__desc">Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="The concept of a task-assignment service—open a case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task Distribution Service Concept</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Product concept for a service to create, assign, and track tasks, with an emphasis on process transparency and seamless teamwork.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Task Tracker for the Back-Office System — Open a Case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task tracker for a back-office system</h3>
<ul class="mp-card__tags" aria-label="Project Type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">An interface for planning and assigning tasks in the operations department: shared pool, personal to-do list, overview by assignee; tabs, filters, board/list/table views, and drag-and-drop functionality.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card portfolio-card--bleed" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-whatsapp" href="https://wa.me/qr/E2AWW5ZHE3ANJ1" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/whatsapp.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">WhatsApp</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+365
View File
@@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="How cookies, localStorage, and related browser technologies are used on Nikolai Mamadaev&#x27;s portfolio website."
/>
<title>Cookie Policy — Nikolai Mamadaev</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.cookie-policy-doc {
max-width: 48rem;
margin: 0 auto;
}
.cookie-policy-doc h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.cookie-policy-doc .section__prose + .section__prose {
margin-top: 0.75rem;
}
.cookie-policy-doc table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
font-size: 0.92rem;
color: var(--text-muted);
}
.cookie-policy-doc th,
.cookie-policy-doc td {
border: 1px solid var(--border);
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.cookie-policy-doc th {
color: var(--text);
font-weight: 600;
background: color-mix(in srgb, var(--surface-elevated) 80%, transparent);
}
.cookie-policy-doc code {
font-family: var(--font);
font-size: 0.88em;
color: var(--text);
}
.cookie-policy-doc .section__prose a {
color: var(--accent);
}
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../" aria-label="Home">NM</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="../">Home</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick Actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style Settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme and Design" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent Color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Saturation and brightness of the accent"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent shade</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="As in the system"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light-colored theme"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark Theme"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner Rounding</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Slight rounding"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Average rounding"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Significant rounding"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page Background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide the background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="../#contact" aria-label="Contact Us">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Content Edits" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Content Editing Mode"
title="Edit Mode"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="main" class="section section--tight">
<div class="section__inner cookie-policy-doc">
<p class="hero__eyebrow">Legal Information</p>
<h1 class="case-page__h1">Cookie and Local Storage Policy</h1>
<p class="section__prose"><strong>Last updated:</strong> May 9, 2026</p>
<p class="section__prose">
This document describes how browser-based data storage technologies are used on Nikolai Mamadaev’s personal portfolio website (hereinafter referred to as the “website”).
</p>
<h2 id="operator">Who Is Responsible for the Website</h2>
<p class="section__prose">
<strong>Nikolai Mamadaev</strong>
is the operator of the Site. For questions regarding data processing, please use the contact information in the “Contacts” section on
<a href="../#contact">the home page</a>
(Telegram, VKontakte, and email by copying the address using the button).
</p>
<h2 id="http-cookie">Are HTTP cookies used?</h2>
<p class="section__prose">
In the current version of the website, <strong>third-party advertising or analytics HTTP cookies are not enabled</strong>. The page markup does not contain analytics trackers (such as Yandex.Metrica or Google Analytics), which typically set their own cookies.
</p>
<p class="section__prose">
When the website is hosted on a server or behind a proxy, technical logs (such as server logs containing IP addresses and request times) may be processed by the hosting provider in accordance with its policies—this is not controlled by the website’s code.
</p>
<h2 id="local-storage">Local Storage and Similar Browser Technologies</h2>
<p class="section__prose">
To improve your experience, the website stores small amounts of data on your device using the browser’s <strong>localStorage mechanism</strong> (this is not an HTTP cookie, but it functions similarly and is also considered client-side storage).
</p>
<p class="section__prose">The following keys are used:</p>
<table>
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>siteThemeV1</code></td>
<td>
Appearance settings: theme (light/dark/system), tint, rounded corners, font, background, and related theme panel settings.
</td>
</tr>
<tr>
<td><code>siteCookieConsentV1</code></td>
<td>Value <code>accepted</code> if you clicked “Accept” in the cookie notification; after that, the banner is hidden.</td>
</tr>
</tbody>
</table>
<p class="section__prose">
This data <strong>is not</strong> automatically <strong>sent to the website’s server</strong>: it remains only in your browser.
</p>
<h2 id="retention">Retention Period</h2>
<p class="section__prose">
Data in localStorage is stored until you <strong>manually delete it</strong> (clear site data / completely clear browser data) or until the browser resets the storage according to its own rules.
</p>
<h2 id="consent">Consent and Opt-Out</h2>
<p class="section__prose">
By clicking “Accept” on the banner, you confirm that you have read this policy. If you do not click “Accept,” the banner remains visible; the website’s functionality is preserved as much as possible, but no record of your consent is created.
</p>
<p class="section__prose">
You can <strong>clear localStorage</strong> for this site at any time in your browser settings—this will reset the saved theme and the notification acceptance flag, and the banner may reappear.
</p>
<h2 id="changes">Changes</h2>
<p class="section__prose">
The text of the policy may be updated as technologies on the site change. The current version is published on the “Cookie Policy” page at <a href="../cookie-policy/">/cookie-policy.html</a> relative to the site root.
</p>
</div>
</main>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> ·
</span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span> ·<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true">·
</span><a href="../cookie-policy/">Cookie policy</a></span> ·<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true">
</span><nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span></div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies to ensure a smooth user experience. For more information, see the
<a href="../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true"><img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" /></span>
</button>
</aside>
</body>
</html>
+1411
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+246 -195
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "assets/fonts/merriweather.css",
geologica: "assets/fonts/geologica.css",
mono: "assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="assets/favicon/site.webmanifest" />
<link rel="preload" href="assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="css/main.css" as="style" />
<link rel="preload" href="js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="js/main.js" />
<link rel="stylesheet" href="css/main.css" />
<script src="js/skill-connectors.js" defer></script>
<script type="module" src="js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
@@ -269,7 +269,7 @@
</div>
</section>
<section class="marquee" aria-hidden="true">
<section class="marquee" aria-hidden="true" data-reveal>
<div class="marquee__track">
<div class="marquee__content">
<span class="hero__tag">UX/UI</span>
@@ -298,172 +298,178 @@
</div>
</section>
<section id="projects" class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Портфолио</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="cases/ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="cases/lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
<section id="projects" class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Портфолио</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--reflect" href="cases/ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="cases/manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="cases/mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="cases/orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="cases/task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="cases/task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--reflect" href="cases/lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--reflect" href="cases/manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--reflect" href="cases/mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--reflect" href="cases/orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--reflect" href="cases/task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--reflect" href="cases/task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
<script>
document.querySelectorAll('.mp-card--reflect').forEach(function (c) {
var i = c.querySelector('.mp-card__image');
if (i) c.style.setProperty('--card-cover', 'url("' + i.src + '")');
});
</script>
</section><section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
@@ -482,7 +488,7 @@
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/orc-automation-panel/">
<a class="portfolio-card portfolio-card--bleed" href="cases/orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Веб-панель для оркестрации автоматизаций</h3>
<p class="portfolio-card__summary">
@@ -508,7 +514,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/ai-platform-primo-rpa/">
<a class="portfolio-card portfolio-card--bleed" href="cases/ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Платформа для работы с AI</h3>
<p class="portfolio-card__summary">
@@ -534,7 +540,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/task-tracker/">
<a class="portfolio-card portfolio-card--bleed" href="cases/task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Таск-трекер для бек-офис системы</h3>
<p class="portfolio-card__summary">
@@ -560,7 +566,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/manu-game-maker/">
<a class="portfolio-card portfolio-card--bleed" href="cases/manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Игровой движок MANU</h3>
<p class="portfolio-card__summary">
@@ -586,7 +592,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/lunacy/">
<a class="portfolio-card portfolio-card--bleed" href="cases/lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Графический редактор Lunacy</h3>
<p class="portfolio-card__summary">
@@ -612,7 +618,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/mobile-clinic-app/">
<a class="portfolio-card portfolio-card--bleed" href="cases/mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Мобильное приложение для сети клиник</h3>
<p class="portfolio-card__summary">
@@ -638,7 +644,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/task-distribution-concept/">
<a class="portfolio-card portfolio-card--bleed" href="cases/task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Концепт сервиса распределения задач</h3>
<p class="portfolio-card__summary">
@@ -664,7 +670,7 @@
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="cases/obsidian-plugin-columns-view/">
<a class="portfolio-card portfolio-card--bleed" href="cases/obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Колоночный вид — плагин для Obsidian</h3>
<p class="portfolio-card__summary">
@@ -1287,6 +1293,36 @@
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://github.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/github_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
GitHub
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мои проекты и эксперименты с кодом.
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
@@ -1343,28 +1379,43 @@
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Яндекс</span><span class="contact-card__hint">Да, тут тоже можно</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Скопировать адрес" aria-label="Скопировать адрес электронной почты в буфер обмена"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Почта</span><span class="contact-card__hint" data-contact-copy-hint>Скопировать адрес</span></span></button></li>
</ul>
</div>
</section>
<section class="section section--surface" aria-hidden="true">
<div class="section__inner"></div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true"> · </span><nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="cookie-policy/" data-cookie-policy-link>политике cookie</a>.
<a href="cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
+2
View File
@@ -20,6 +20,7 @@ import { initHeroMotionVideo } from "./modules/ui/hero-motion-video.js";
import { initPageMeshBackground } from "./modules/ui/page-mesh-bg.js";
import { initPrimaryButtonShine } from "./modules/ui/primary-button-shine.js";
import { initProjectsCarousel } from "./modules/ui/projects-carousel.js";
import { initCaseMediaSlider } from "./modules/cases/media-slider.js";
import { initSkillsAccordion } from "./modules/ui/skills-accordion.js";
import { initSkillsIntroMessages } from "./modules/ui/skills-intro.js";
import { initContentEditMode } from "./modules/ui/content-edit/content-edit-mode.js";
@@ -35,6 +36,7 @@ const miniProjectsMasonry = initMiniProjectsMasonry();
initThemeControls();
initPrimaryButtonShine();
initProjectsCarousel();
initCaseMediaSlider();
initDeferredPortfolioCovers();
initImageLoadPlaceholder();
initSkillsAccordion();
+145 -20
View File
@@ -15,20 +15,31 @@ export function initCaseImageViewer() {
overlay.hidden = true;
overlay.innerHTML = `
<div class="case-image-viewer__toolbar glass-neutral" role="toolbar" aria-label="Просмотр изображения">
<span class="case-image-viewer__zoom" data-imgv-zoom-label>100%</span>
<input class="case-image-viewer__slider" type="range" min="1" max="150" step="1" value="100" data-imgv-slider aria-label="Масштаб изображения" />
<div class="case-image-viewer__nav-group" role="group" aria-label="Навигация по изображениям">
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn case-image-viewer__btn--nav" data-imgv-prev aria-label="Предыдущее изображение" hidden>
<span class="case-image-viewer__btn-icon icon-press" style="--imgv-icon:url('../assets/shared/icons/shevron-left.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn case-image-viewer__btn--nav" data-imgv-next aria-label="Следующее изображение" hidden>
<span class="case-image-viewer__btn-icon icon-press" style="--imgv-icon:url('../assets/shared/icons/shevron-right.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="case-image-viewer__dots" data-imgv-dots role="tablist" aria-label="Выбор изображения" hidden></div>
<div class="case-image-viewer__scale-group" role="group" aria-label="Масштаб">
<input class="case-image-viewer__slider" type="range" min="1" max="150" step="1" value="100" data-imgv-slider aria-label="Масштаб изображения" />
<span class="case-image-viewer__zoom" data-imgv-zoom-label>100%</span>
</div>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-fit aria-label="Уместить на экран">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/zoom_to_fit.svg')" aria-hidden="true"></span>
<span class="case-image-viewer__btn-icon icon-press" style="--imgv-icon:url('../assets/shared/icons/zoom_to_fit.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-original aria-label="Оригинальный размер">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/original_size.svg')" aria-hidden="true"></span>
<span class="case-image-viewer__btn-icon icon-press" style="--imgv-icon:url('../assets/shared/icons/original_size.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-close aria-label="Закрыть просмотр">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/close.svg')" aria-hidden="true"></span>
<span class="case-image-viewer__btn-icon icon-press" style="--imgv-icon:url('../assets/shared/icons/close.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="case-image-viewer__stage" data-imgv-stage tabindex="0">
<div class="case-image-viewer__loading" data-imgv-loading aria-live="polite">Загрузка...</div>
<div class="case-image-viewer__loader" data-imgv-loading aria-label="Загрузка изображения" role="status"><span class="tui-spinner" aria-hidden="true"><span>⠋</span><span>⠙</span><span>⠹</span><span>⠸</span><span>⠼</span><span>⠴</span><span>⠦</span><span>⠧</span><span>⠇</span><span>⠏</span></span><span class="case-image-viewer__loader-msg" data-imgv-loader-msg></span></div>
<img class="case-image-viewer__img" data-imgv-img alt="" />
</div>
`;
@@ -37,11 +48,15 @@ export function initCaseImageViewer() {
const stage = overlay.querySelector("[data-imgv-stage]");
const viewImg = overlay.querySelector("[data-imgv-img]");
const loading = overlay.querySelector("[data-imgv-loading]");
const loaderMsg = overlay.querySelector("[data-imgv-loader-msg]");
const zoomLabel = overlay.querySelector("[data-imgv-zoom-label]");
const zoomSlider = overlay.querySelector("[data-imgv-slider]");
const btnFit = overlay.querySelector("[data-imgv-fit]");
const btnOriginal = overlay.querySelector("[data-imgv-original]");
const btnClose = overlay.querySelector("[data-imgv-close]");
const btnPrev = overlay.querySelector("[data-imgv-prev]");
const btnNext = overlay.querySelector("[data-imgv-next]");
const dotsWrap = overlay.querySelector("[data-imgv-dots]");
let scale = 1;
let naturalW = 0;
@@ -56,11 +71,33 @@ export function initCaseImageViewer() {
let panScrollLeft = 0;
let panScrollTop = 0;
let panMoved = false;
let panDirX = 0;
let closeTimer = 0;
let openRaf = 0;
const viewerAnimMs = 210;
let centeredAnchor = null;
// Состояние листалки: группа картинок (например, одна галерея) + индекс в ней.
let groupImgs = [];
let groupIndex = -1;
function resolveGroup(img) {
// Поднимаемся до ближайшего контейнера группы, пропуская отдельные ячейки/слайды.
const container = img.closest(
".case-media-gallery, .case-media-slider, .case-media, figure",
);
const scope = container || img.parentElement || document;
const list = Array.from(
scope.querySelectorAll("img[data-image-viewer]"),
).filter((el) => {
if (el.hasAttribute("data-src")) return false;
const s = el.getAttribute("src") || el.currentSrc || "";
if (isViewerThumbnailPlaceholder(s)) return false;
return true;
});
return list;
}
function measureToolbarSpace() {
const toolbarRect = overlay.querySelector(".case-image-viewer__toolbar")?.getBoundingClientRect();
const next = toolbarRect ? Math.ceil(toolbarRect.height + 22) : 0;
@@ -160,7 +197,12 @@ export function initCaseImageViewer() {
centeredAnchor = null;
}
function openViewer(src, alt) {
function showAt(group, index) {
if (index < 0 || index >= group.length) return;
groupIndex = index;
const img = group[groupIndex];
const src = img.getAttribute("src") || img.currentSrc || "";
const alt = img.alt || "";
if (closeTimer) {
window.clearTimeout(closeTimer);
closeTimer = 0;
@@ -171,15 +213,52 @@ export function initCaseImageViewer() {
}
overlay.classList.remove("is-open");
overlay.classList.add("is-loading");
if (loading) loading.classList.remove("is-error");
if (loaderMsg) loaderMsg.textContent = "";
centeredAnchor = null;
viewImg.onload = null;
viewImg.onerror = null;
viewImg.src = src;
viewImg.alt = alt || "";
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
overlay.hidden = false;
document.documentElement.classList.add("imgv-lock");
active = true;
viewImg.alt = alt;
// Открываем просмотрщик сразу (поверх показывается спиннер), не дожидаясь загрузки.
openRaf = window.requestAnimationFrame(() => {
overlay.classList.add("is-open");
openRaf = 0;
});
// Навигация внутри группы (галерея / слайдер). Скрываем, если картинка одна.
const multi = group.length > 1;
btnPrev.hidden = !multi;
btnNext.hidden = !multi;
if (multi) {
// Точки-пагинация в тулбаре.
if (dotsWrap.childElementCount !== group.length) {
dotsWrap.innerHTML = group
.map(
(_, i) =>
`<button type="button" class="case-image-viewer__dot" data-imgv-dot="${i}" role="tab" aria-label="Перейти к изображению ${i + 1}"></button>`,
)
.join("");
dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot) => {
dot.addEventListener("click", (e) => {
e.stopPropagation();
const i = Number(dot.getAttribute("data-imgv-dot"));
showAt(group, i);
});
});
}
dotsWrap.hidden = false;
dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot, i) => {
const active = i === groupIndex;
dot.classList.toggle("is-active", active);
dot.setAttribute("aria-selected", active ? "true" : "false");
dot.tabIndex = active ? 0 : -1;
});
btnPrev.disabled = groupIndex === 0;
btnNext.disabled = groupIndex === group.length - 1;
} else {
dotsWrap.hidden = true;
}
const applyAfterLoad = () => {
overlay.classList.remove("is-loading");
@@ -203,10 +282,10 @@ export function initCaseImageViewer() {
const applyLoadError = () => {
overlay.classList.remove("is-loading");
if (loading) loading.textContent = "Не удалось загрузить";
if (loading) loading.classList.add("is-error");
if (loaderMsg) loaderMsg.textContent = "Не удалось загрузить";
};
if (loading) loading.textContent = "Загрузка...";
if (viewImg.complete && viewImg.naturalWidth > 0) applyAfterLoad();
else {
viewImg.onload = applyAfterLoad;
@@ -228,7 +307,8 @@ export function initCaseImageViewer() {
overlay.hidden = true;
stage.classList.remove("is-center-y");
stage.classList.remove("is-centered");
if (loading) loading.textContent = "Загрузка...";
if (loading) loading.classList.remove("is-error");
if (loaderMsg) loaderMsg.textContent = "";
viewImg.onload = null;
viewImg.onerror = null;
viewImg.removeAttribute("src");
@@ -244,12 +324,20 @@ export function initCaseImageViewer() {
const t = e.target;
if (!(t instanceof Element)) return;
const img = t.closest("img[data-image-viewer]");
if (!img || img.closest(".case-media--natural")) return;
if (!img) return;
if (img.hasAttribute("data-src")) return;
const src = img.currentSrc || img.getAttribute("src") || "";
if (!src || isViewerThumbnailPlaceholder(src)) return;
e.preventDefault();
openViewer(src, img.alt || "");
const group = resolveGroup(img);
const index = group.indexOf(img);
groupImgs = group;
groupIndex = index >= 0 ? index : 0;
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
overlay.hidden = false;
document.documentElement.classList.add("imgv-lock");
active = true;
showAt(groupImgs, groupIndex >= 0 ? groupIndex : 0);
},
true,
);
@@ -265,8 +353,24 @@ export function initCaseImageViewer() {
btnOriginal.addEventListener("click", () => zoomTo(1));
btnClose.addEventListener("click", closeViewer);
function navigateGroup(dir) {
if (!groupImgs.length) return;
const next = Math.min(Math.max(groupIndex + dir, 0), groupImgs.length - 1);
if (next === groupIndex) return;
showAt(groupImgs, next);
}
btnPrev.addEventListener("click", (e) => {
e.stopPropagation();
navigateGroup(-1);
});
btnNext.addEventListener("click", (e) => {
e.stopPropagation();
navigateGroup(1);
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeViewer();
if (e.target === overlay && !overlay.classList.contains("is-loading")) closeViewer();
});
stage.addEventListener(
@@ -300,7 +404,10 @@ export function initCaseImageViewer() {
if (!active || !isPanning) return;
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) panMoved = true;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
panMoved = true;
panDirX = dx;
}
stage.scrollLeft = panScrollLeft - dx;
stage.scrollTop = panScrollTop - dy;
e.preventDefault();
@@ -314,9 +421,16 @@ export function initCaseImageViewer() {
}
stage.addEventListener("pointerup", (e) => {
const horizSwipe = panMoved && Math.abs(panDirX) > 40 && groupImgs.length > 1;
const shouldClose = !panMoved;
stopPan(e.pointerId);
if (shouldClose && active) {
if (horizSwipe) {
e.preventDefault();
navigateGroup(panDirX < 0 ? 1 : -1);
return;
}
// Пока грузится изображение — не закрываем по тапу/клику.
if (shouldClose && active && !overlay.classList.contains("is-loading")) {
e.preventDefault();
closeViewer();
}
@@ -365,6 +479,17 @@ export function initCaseImageViewer() {
if (e.key === "0") {
e.preventDefault();
zoomTo(fitScale());
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
navigateGroup(-1);
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
navigateGroup(1);
return;
}
});
}
+209
View File
@@ -0,0 +1,209 @@
// Слайдер изображений для лонгридов кейсов (одна картинка + стрелки + точки).
// Разметка:
// <figure class="case-media case-media-slider" data-case-media-slider>
// <div class="case-media-slider__viewport" data-slider-viewport>
// <div class="case-media-slider__track" data-slider-track>
// <div class="case-media-slider__slide"><img ... data-image-viewer [data-src="..."]></div>
// ...
// </div>
// </div>
// <button class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">…</button>
// <button class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">…</button>
// <div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
// <button class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению N"></button>
// ...
// </div>
// </figure>
function clampIndex(i, len) {
if (len <= 0) return 0;
return Math.min(Math.max(i, 0), len - 1);
}
function loadSlide(slide) {
if (!slide) return;
const img = slide.querySelector("img[data-src]");
if (img && img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
}
function loadAround(slides, index) {
loadSlide(slides[index]);
loadSlide(slides[index - 1]);
loadSlide(slides[index + 1]);
}
export function initCaseMediaSlider() {
const roots = document.querySelectorAll("[data-case-media-slider]");
roots.forEach((root) => {
const viewport = root.querySelector("[data-slider-viewport]");
const track = root.querySelector("[data-slider-track]");
const prevBtn = root.querySelector("[data-slider-prev]");
const nextBtn = root.querySelector("[data-slider-next]");
const navBtns = [prevBtn, nextBtn].filter(Boolean);
// Имитация :active для иконки (swipe-обработчик viewport сбрасывает нативный :active)
navBtns.forEach((btn) => {
const press = () => btn.classList.add("is-pressed");
const release = () => btn.classList.remove("is-pressed");
btn.addEventListener("pointerdown", press);
btn.addEventListener("mousedown", press);
btn.addEventListener("pointerup", release);
btn.addEventListener("mouseup", release);
btn.addEventListener("pointerleave", release);
btn.addEventListener("mouseleave", release);
btn.addEventListener("pointercancel", release);
});
const dotsWrap = root.querySelector("[data-slider-dots]");
if (!viewport || !track) return;
// Авто-подстройка ширины под первую картинку — только для --natural (auto) слайдеров,
// и только если --slider-natural-w не задана вручную. Базовый/wide управляются CSS.
const firstImg = root.querySelector(".case-media-slider__slide img");
const applyAutoWidth = (img) => {
if (root.style.getPropertyValue("--slider-natural-w")) return;
const nw = img.naturalWidth || 0;
if (nw > 0) root.style.setProperty("--slider-natural-w", `${nw}px`);
};
if (root.classList.contains("case-media--natural") && firstImg) {
if (firstImg.complete && firstImg.naturalWidth > 0) {
applyAutoWidth(firstImg);
} else {
firstImg.addEventListener("load", () => applyAutoWidth(firstImg), { once: true });
}
}
const slides = Array.from(track.querySelectorAll(":scope > .case-media-slider__slide"));
const dots = dotsWrap
? Array.from(dotsWrap.querySelectorAll(":scope > [data-slider-dot]"))
: [];
const count = slides.length;
if (count === 0) return;
let current = 0;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
// Режим анимации переключения: fade (по умолчанию) / slide / none
let mode = root.getAttribute("data-slider-anim") || "fade";
function applyMode(m) {
mode = m;
root.classList.remove(
"case-media-slider--anim-slide",
"case-media-slider--anim-fade",
"case-media-slider--anim-none"
);
root.classList.add(`case-media-slider--anim-${m}`);
if (m === "fade") {
track.style.transform = "none";
} else {
track.style.transform = `translateX(${-100 * current}%)`;
}
}
applyMode(mode);
function goTo(index, behavior) {
const next = clampIndex(index, count);
current = next;
if (mode !== "fade") {
const offset = -100 * next;
track.style.transform = `translateX(${offset}%)`;
} else {
track.style.transform = "none";
}
track.style.transition = reduceMotion || behavior === "auto"
? "none"
: "";
slides.forEach((s, i) => {
s.classList.toggle("is-active", i === next);
s.setAttribute("aria-hidden", i === next ? "false" : "true");
});
dots.forEach((d, i) => {
const active = i === next;
d.classList.toggle("is-active", active);
d.setAttribute("aria-selected", active ? "true" : "false");
d.tabIndex = active ? 0 : -1;
});
if (prevBtn) prevBtn.disabled = next === 0;
if (nextBtn) nextBtn.disabled = next === count - 1;
loadAround(slides, next);
}
prevBtn?.addEventListener("click", () => goTo(current - 1));
nextBtn?.addEventListener("click", () => goTo(current + 1));
dots.forEach((dot, i) => {
dot.addEventListener("click", () => goTo(i));
});
// Клавиатура: стрелки при фокусе внутри слайдера.
root.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
goTo(current - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
goTo(current + 1);
}
});
// Поддержка свопа по клику на точку при навигации с клавиатуры.
dotsWrap?.addEventListener("keydown", (e) => {
if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(count - 1);
}
});
// Свайп на тач/мыши (только горизонтальный).
let drag = null;
viewport.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
drag = { x: e.clientX, moved: false };
});
viewport.addEventListener("pointermove", (e) => {
if (!drag) return;
if (Math.abs(e.clientX - drag.x) > 6) drag.moved = true;
});
function endDrag(e) {
if (!drag) return;
const dx = e.clientX - drag.x;
if (drag.moved && Math.abs(dx) > 40) {
goTo(current + (dx < 0 ? 1 : -1));
}
drag = null;
}
viewport.addEventListener("pointerup", endDrag);
viewport.addEventListener("pointercancel", endDrag);
// Инициализация.
slides.forEach((s, i) => {
s.setAttribute("aria-hidden", i === 0 ? "false" : "true");
s.classList.toggle("is-active", i === 0);
});
goTo(0, "auto");
// UI-переключатель режима анимации (для демо-примера), если есть в разметке.
const animSwitch = root.querySelector("[data-slider-anim-switch]");
if (animSwitch) {
const btns = Array.from(animSwitch.querySelectorAll("button"));
const sync = () => btns.forEach((b) => {
b.setAttribute("aria-pressed", String(b.dataset.mode === mode));
});
sync();
btns.forEach((b) => {
b.addEventListener("click", () => {
applyMode(b.dataset.mode || "slide");
sync();
});
});
}
});
}
@@ -1,5 +1,7 @@
export function initCaseNaturalImagesHalfSize() {
const images = Array.from(document.querySelectorAll(".case-media--natural img"));
const images = Array.from(
document.querySelectorAll(".case-media--natural img")
).filter((img) => !img.closest(".case-media-slider"));
if (!images.length) return;
function applyHalfSize(img) {
+15 -1
View File
@@ -10,7 +10,20 @@ export function initNavOverflow() {
const links = Array.from(nav.querySelectorAll("a"));
const MENU_GAP = 6;
const VIEWPORT_PAD = 8;
function getEdgePad() {
const raw = getComputedStyle(document.documentElement).getPropertyValue("--edge-pad").trim();
if (!raw) return 8;
// resolve через временный элемент, т.к. значение — max(...clamp...)
const probe = document.createElement("span");
probe.style.all = "unset";
probe.style.position = "fixed";
probe.style.left = raw;
document.body.appendChild(probe);
const px = probe.getBoundingClientRect().left;
probe.remove();
return Math.max(0, Math.round(px));
}
let VIEWPORT_PAD = getEdgePad();
let openInFloatingMode = null;
let resizeRaf = 0;
@@ -139,6 +152,7 @@ export function initNavOverflow() {
const onResize = () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
VIEWPORT_PAD = getEdgePad();
layoutLinks();
placeMoreMenu();
resizeRaf = 0;
+33 -12
View File
@@ -7,6 +7,7 @@ import {
L_RANGE,
RADIUS_IDS,
THEME_MODE_IDS,
effectiveThemeMode,
FONT_IDS,
MESH_BG_IDS,
applyThemeState,
@@ -40,9 +41,13 @@ export function initThemeControls() {
applyThemeState(state);
ensureThemeFontLoaded(state.font);
function acc() {
return state[effectiveThemeMode(state)] || state.light;
}
function syncHueSlider() {
hueSlider.value = String(Math.round(state.h));
hueSlider.setAttribute("aria-valuenow", String(Math.round(state.h)));
hueSlider.value = String(Math.round(acc().h));
hueSlider.setAttribute("aria-valuenow", String(Math.round(acc().h)));
}
function persistAndApply() {
@@ -65,8 +70,8 @@ export function initThemeControls() {
}
syncHueSlider();
drawSLPlane(canvas, state.h);
updateKnob(knob, state.s, state.l);
drawSLPlane(canvas, acc().h);
updateKnob(knob, acc().s, acc().l);
syncAriaRadioGroup(radiusBtns, "data-radius-option", state.radius);
syncAriaRadioGroup(themeModeBtns, "data-theme-mode", state.mode);
syncAriaRadioGroup(fontBtns, "data-font-option", state.font);
@@ -75,6 +80,15 @@ export function initThemeControls() {
bindOptionButtons(radiusBtns, "data-radius-option", RADIUS_IDS, "radius");
bindOptionButtons(fontBtns, "data-font-option", FONT_IDS, "font");
bindOptionButtons(themeModeBtns, "data-theme-mode", THEME_MODE_IDS, "mode");
themeModeBtns.forEach((btn) =>
btn.addEventListener("click", () => {
if (!panel.hidden) {
syncHueSlider();
drawSLPlane(canvas, acc().h);
updateKnob(knob, acc().s, acc().l);
}
}),
);
if (meshBgBtns.length >= 2) bindOptionButtons(meshBgBtns, "data-mesh-bg-option", MESH_BG_IDS, "meshBg");
function setPanelOpen(open) {
@@ -84,8 +98,8 @@ export function initThemeControls() {
btn.setAttribute("aria-label", open ? "Закрыть настройки стиля" : "Настройки стиля");
});
if (open) {
drawSLPlane(canvas, state.h);
updateKnob(knob, state.s, state.l);
drawSLPlane(canvas, acc().h);
updateKnob(knob, acc().s, acc().l);
syncAriaRadioGroup(themeModeBtns, "data-theme-mode", state.mode);
syncAriaRadioGroup(fontBtns, "data-font-option", state.font);
if (meshBgBtns.length >= 2) syncAriaRadioGroup(meshBgBtns, "data-mesh-bg-option", state.meshBg);
@@ -95,6 +109,11 @@ export function initThemeControls() {
subscribePrefersColorSchemeChange(() => {
if (state.mode !== "system") return;
applyThemeState(state);
if (!panel.hidden) {
syncHueSlider();
drawSLPlane(canvas, acc().h);
updateKnob(knob, acc().s, acc().l);
}
});
panelBtns.forEach((btn) => {
@@ -143,9 +162,10 @@ export function initThemeControls() {
if (r.width <= 0 || r.height <= 0) return;
const x = clampNum(((clientX - r.left) / r.width) * 100, 0, 100, 0) / 100;
const y = clampNum(((clientY - r.top) / r.height) * 100, 0, 100, 0) / 100;
state.s = Math.round(PICK.sMin + x * S_RANGE);
state.l = Math.round(PICK.lMin + (1 - y) * L_RANGE);
updateKnob(knob, state.s, state.l);
const a = acc();
a.s = Math.round(PICK.sMin + x * S_RANGE);
a.l = Math.round(PICK.lMin + (1 - y) * L_RANGE);
updateKnob(knob, a.s, a.l);
persistAndApply();
}
@@ -168,10 +188,11 @@ export function initThemeControls() {
});
hueSlider.addEventListener("input", () => {
state.h = clampNum(hueSlider.value, 0, 360, state.h);
const a = acc();
a.h = clampNum(hueSlider.value, 0, 360, a.h);
syncHueSlider();
drawSLPlane(canvas, state.h);
updateKnob(knob, state.s, state.l);
drawSLPlane(canvas, a.h);
updateKnob(knob, a.s, a.l);
persistAndApply();
});
}
+53 -15
View File
@@ -27,7 +27,14 @@ export const S_RANGE = PICK.sMax - PICK.sMin;
export const L_RANGE = PICK.lMax - PICK.lMin;
export function defaultThemeState() {
return { mode: "system", h: 210, s: 90, l: 40, radius: "medium", font: "geologica", meshBg: "on" };
return {
mode: "system",
light: { h: 210, s: 90, l: 40 },
dark: { h: 210, s: 90, l: 40 },
radius: "medium",
font: "geologica",
meshBg: "on",
};
}
export function effectiveThemeMode(state) {
@@ -35,23 +42,53 @@ export function effectiveThemeMode(state) {
return state.mode;
}
export function activeAccent(state) {
const key = effectiveThemeMode(state);
return state[key] || state.light || { h: 210, s: 90, l: 40 };
}
function parseAccent(obj, base) {
if (!obj) return { ...base };
return {
h: clampNum(obj.h ?? obj.hue, 0, 360, base.h),
s: clampNum(obj.s ?? obj.sat, PICK.sMin, PICK.sMax, base.s),
l: clampNum(obj.l ?? obj.light, PICK.lMin, PICK.lMax, base.l),
};
}
export function readThemeState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultThemeState();
const o = JSON.parse(raw);
const base = defaultThemeState();
const hRaw = o.h ?? o.hue;
const h = clampNum(hRaw, 0, 360, base.h);
const s = clampNum(o.s ?? o.sat, PICK.sMin, PICK.sMax, base.s);
const lRaw = o.l ?? o.light;
const l = clampNum(lRaw, PICK.lMin, PICK.lMax, base.l);
const mode =
o.mode === "light" || o.mode === "dark" || o.mode === "system" ? o.mode : base.mode;
const radius = RADIUS_IDS.includes(o.radius) ? o.radius : base.radius;
const font = normalizeFontId(o.font ?? base.font);
const meshBg = MESH_BG_IDS.includes(o.meshBg) ? o.meshBg : base.meshBg;
return { mode, h, s, l, radius, font, meshBg };
let light = base.light;
let dark = base.dark;
if (o.light || o.dark) {
light = parseAccent(o.light, base.light);
dark = parseAccent(o.dark, base.dark);
} else if (
o.h !== undefined ||
o.hue !== undefined ||
o.s !== undefined ||
o.sat !== undefined ||
o.l !== undefined ||
o.light !== undefined
) {
// legacy: единый акцент → копируем в обе темы
const h = clampNum(o.h ?? o.hue, 0, 360, base.light.h);
const s = clampNum(o.s ?? o.sat, PICK.sMin, PICK.sMax, base.light.s);
const l = clampNum(o.l ?? o.light, PICK.lMin, PICK.lMax, base.light.l);
light = { h, s, l };
dark = { h, s, l };
}
return { mode, light, dark, radius, font, meshBg };
} catch {
return defaultThemeState();
}
@@ -62,9 +99,8 @@ export function writeThemeState(state) {
STORAGE_KEY,
JSON.stringify({
mode: state.mode,
h: state.h,
s: state.s,
l: state.l,
light: state.light,
dark: state.dark,
radius: state.radius,
font: state.font,
meshBg: state.meshBg,
@@ -110,14 +146,16 @@ export function relativeLuminance(rgb) {
export function applyThemeState(state) {
const root = document.documentElement;
root.dataset.theme = effectiveThemeMode(state);
const mode = effectiveThemeMode(state);
root.dataset.theme = mode;
root.dataset.radius = state.radius;
root.dataset.font = state.font;
root.dataset.meshBg = state.meshBg === "off" ? "off" : "on";
root.style.setProperty("--theme-h", String(state.h));
root.style.setProperty("--theme-s", String(state.s));
root.style.setProperty("--theme-l", String(state.l));
const rgb = hslToRgb(state.h, state.s, state.l);
const acc = activeAccent(state);
root.style.setProperty("--theme-h", String(acc.h));
root.style.setProperty("--theme-s", String(acc.s));
root.style.setProperty("--theme-l", String(acc.l));
const rgb = hslToRgb(acc.h, acc.s, acc.l);
const lum = relativeLuminance(rgb);
root.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
window.dispatchEvent(new CustomEvent("site-theme-applied", { bubbles: true }));
@@ -3,7 +3,7 @@ import { isTinyPlaceholderSrc } from "../shared/image-src.js";
const HOST_SELECTOR =
".case-media, .case-media-gallery__item, .case-link-card__media, .case-media-tall__clip, .mp-card__media, .portfolio-card__cover, figure";
const EXCLUDE_ANCESTOR = ".contact-card__icon, .cookie-notice, .case-image-viewer, [data-no-img-placeholder]";
const EXCLUDE_ANCESTOR = ".contact-card__icon, .cookie-notice, .case-image-viewer, .case-media-slider, [data-no-img-placeholder]";
function shouldWatch(img) {
if (!(img instanceof HTMLImageElement)) return false;
+5 -5
View File
@@ -249,10 +249,10 @@
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="index.html" aria-label="На главную">НМ</a>
<a class="logo" href="./" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<a href="index.html">На главную</a>
<a href="./">На главную</a>
</nav>
</div>
</div>
@@ -315,7 +315,7 @@
</div>
</div>
</div>
<a class="fab fab--primary" href="index.html#contact" aria-label="Связаться">
<a class="fab fab--primary" href="./#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
@@ -494,14 +494,14 @@
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="cookie-policy.html">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="cookie-policy/">Политика cookie</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="cookie-policy.html" data-cookie-policy-link>политике cookie</a>.
<a href="cookie-policy/" data-cookie-policy-link>политике cookie</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
+164 -145
View File
@@ -8,99 +8,99 @@
content="Резюме Николая Мамадаева — продуктовый дизайнер с опытом более 10 лет."
/>
<title>Резюме — Николай Мамадаев</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "../assets/fonts/merriweather.css",
geologica: "../assets/fonts/geologica.css",
mono: "../assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../assets/favicon/site.webmanifest" />
<link rel="preload" href="../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../css/main.css" as="style" />
<link rel="preload" href="../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../js/main.js" />
<link rel="stylesheet" href="../css/main.css" />
<script src="../js/skill-connectors.js" defer></script>
<script type="module" src="../js/main.js"></script>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.resume-doc {
@@ -302,18 +302,18 @@
</style>
</head>
<body>
<a class="skip-link" href="#main">К основному содержимому</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../index.html" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<a href="../index.html">На главную</a>
</nav>
</div>
</div>
</header>
<a class="skip-link" href="#main">К основному содержимому</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы">
<a href="../">На главную</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
@@ -326,27 +326,27 @@
<span class="fab__label">Скачать в PDF</span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="main" class="section section--surface section--tight">
@@ -944,26 +944,45 @@
</div>
</main>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../cookie-policy/">Политика cookie</a></span>
</div>
</footer>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--lang"><span class="footer__sep" aria-hidden="true"> · </span><nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../cookie-policy/" data-cookie-policy-link>политике cookie</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
Сайт использует cookie для комфортной работы. Подробнее — в
<a href="../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Принять</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<script>
(function () {
+98 -45
View File
@@ -4,10 +4,16 @@ import path from "node:path";
const rootDir = process.cwd();
const srcPagesDir = path.join(rootDir, "src", "pages");
const srcPartialsDir = path.join(rootDir, "src", "partials");
const srcI18nDir = path.join(rootDir, "src", "i18n");
const outDir = path.join(rootDir, "public");
// Локали: дефолтная (ru) собирается в корень public/, остальные — в public/<lang>/.
const LOCALES = ["ru", "en"];
const DEFAULT_LOCALE = "ru";
const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi;
const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
const i18nRe = /\{\{\s*i18n:([\w.-]+)\s*\}\}/g;
function readUtf8(filePath) {
return fs.readFileSync(filePath, "utf8");
@@ -37,20 +43,38 @@ function loadPartials() {
return partialMap;
}
function renderTemplate(input, partials, vars, stack = []) {
function loadI18n() {
const map = {};
for (const loc of LOCALES) {
const file = path.join(srcI18nDir, `${loc}.json`);
map[loc] = fs.existsSync(file) ? JSON.parse(readUtf8(file)) : {};
}
return map;
}
function resolveI18n(key, dict, fallback) {
if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return String(dict[key]);
if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return String(fallback[key]);
return `{{i18n:${key}}}`;
}
function renderTemplate(input, partials, vars, dict, fallback, loc, stack = []) {
const withIncludes = input.replace(includeRe, (_, name) => {
const key = String(name).trim();
if (!partials.has(key)) {
const partialKey = loc && loc !== DEFAULT_LOCALE && partials.has(`${key}.${loc}`) ? `${key}.${loc}` : key;
if (!partials.has(partialKey)) {
throw new Error(`Partial "${key}" not found`);
}
if (stack.includes(key)) {
throw new Error(`Circular partial include: ${[...stack, key].join(" -> ")}`);
if (stack.includes(partialKey)) {
throw new Error(`Circular partial include: ${[...stack, partialKey].join(" -> ")}`);
}
const partialContent = partials.get(key);
return renderTemplate(partialContent, partials, vars, [...stack, key]);
const partialContent = partials.get(partialKey);
return renderTemplate(partialContent, partials, vars, dict, fallback, loc, [...stack, partialKey]);
});
return withIncludes.replace(varRe, (_, name) => {
const withI18n = withIncludes.replace(i18nRe, (_, key) => resolveI18n(String(key).trim(), dict, fallback));
return withI18n.replace(varRe, (_, name) => {
const key = String(name).trim();
if (Object.prototype.hasOwnProperty.call(vars, key)) {
return String(vars[key]);
@@ -93,9 +117,29 @@ function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) {
}
}
/** Базовые переменны для страницы в дефолтной локали (как было до i18n). */
function baseVars(relNorm, depth) {
if (relNorm.startsWith("cases/")) {
return { assetPrefix: "../../", casesPrefix: "../" };
}
if (relNorm === "resume.html") {
return { assetPrefix: "../", casesPrefix: "" };
}
if (relNorm === "cookie-policy.html") {
return { assetPrefix: "../", casesPrefix: "" };
}
return {
assetPrefix: depth === 0 ? "" : "../".repeat(depth),
casesPrefix: depth === 0 ? "cases/" : "",
};
}
function build() {
const partials = loadPartials();
const pageFiles = walkHtmlFiles(srcPagesDir);
const i18n = loadI18n();
const defaultDict = i18n[DEFAULT_LOCALE] || {};
const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i");
const pageFiles = walkHtmlFiles(srcPagesDir).filter((p) => !localeSuffixRe.test(p));
for (const pagePath of pageFiles) {
const rel = path.relative(srcPagesDir, pagePath);
@@ -105,48 +149,57 @@ function build() {
const isCase = relNorm.startsWith("cases/");
const isResume = relNorm === "resume.html";
const isCookiePolicy = relNorm === "cookie-policy.html";
const omitFile = isCase ? path.basename(pagePath) : null;
let outPath;
let vars;
for (const loc of LOCALES) {
const dict = i18n[loc] || {};
const isDefault = loc === DEFAULT_LOCALE;
const langPrefix = isDefault ? "" : `${loc}/`;
if (isCase) {
const slug = path.basename(pagePath, ".html");
outPath = path.join(outDir, "cases", slug, "index.html");
vars = {
assetPrefix: "../../",
casesPrefix: "../",
};
} else if (isResume) {
outPath = path.join(outDir, "resume", "index.html");
vars = {
assetPrefix: "../",
casesPrefix: "",
};
} else if (isCookiePolicy) {
outPath = path.join(outDir, "cookie-policy", "index.html");
vars = {
assetPrefix: "../",
casesPrefix: "",
};
} else {
outPath = path.join(outDir, rel);
vars = {
assetPrefix: depth === 0 ? "" : "../".repeat(depth),
casesPrefix: depth === 0 ? "cases/" : "",
};
// Переведённый исходник страницы для локали, если есть.
let sourcePath = pagePath;
if (!isDefault) {
const override = pagePath.replace(/\.html$/, `.${loc}.html`);
if (fs.existsSync(override)) sourcePath = override;
}
const source = readUtf8(sourcePath);
const vars = baseVars(relNorm, depth);
// Префикс до КОРНЯ ЛОКАЛИ (без en-сдвига) — для навигационных ссылок,
// чтобы они вели в ту же локаль (/en/), а не в корень сайта (/).
vars.pagePrefix = vars.assetPrefix;
// Для не-дефолтной локали всё содержимое уходит на уровень глубже (public/<lang>/),
// поэтому к assetPrefix добавляем один ../, а пустой casesPrefix — в ../cases/.
if (!isDefault) {
vars.assetPrefix = vars.assetPrefix === "" ? "../" : vars.assetPrefix + "../";
if (vars.casesPrefix === "") vars.casesPrefix = "../cases/";
}
vars.lang = loc;
let outPath;
if (isCase) {
const slug = path.basename(pagePath, ".html");
outPath = path.join(outDir, langPrefix, "cases", slug, "index.html");
} else if (isResume) {
outPath = path.join(outDir, langPrefix, "resume", "index.html");
} else if (isCookiePolicy) {
outPath = path.join(outDir, langPrefix, "cookie-policy", "index.html");
} else {
outPath = path.join(outDir, langPrefix, rel);
}
let rendered = renderTemplate(source, partials, vars, dict, defaultDict, loc);
// Подменяем язык в <html lang="..."> под текущую локаль.
rendered = rendered.replace(/<html\s+lang="ru"/, `<html lang="${loc}"`);
if (isCase) {
rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile);
}
ensureDir(path.dirname(outPath));
fs.writeFileSync(outPath, rendered, "utf8");
}
const source = readUtf8(pagePath);
let rendered = renderTemplate(source, partials, vars);
if (isCase) {
const omitFile = path.basename(pagePath);
rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile);
}
ensureDir(path.dirname(outPath));
fs.writeFileSync(outPath, rendered, "utf8");
}
console.log(`Built ${pageFiles.length} pages into public/`);
console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`);
}
build();
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* translate.mjs — CLI-обёртка над apinex.bond /v1/tools/translator
*
* Использование:
* node scripts/translate.mjs --target EN --text "Привет"
* node scripts/translate.mjs --target EN --source RU --file page.html --out page.en.html
* cat strings.txt | node scripts/translate.mjs --target EN --batch
* node scripts/translate.mjs --list-languages
*
* Ключ берётся из HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY, иначе из /root/.hermes/.env.
*
* Для HTML (--html или авто) шаблонные токены {{...}} и содержимое <code> защищаются
* HTML-комментариями, чтобы переводчик их не трогал (они восстанавливаются после).
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const API_URL = "https://api.apinex.bond/v1/tools/translator";
const LANG_URL = "https://api.apinex.bond/v1/tools/translator/languages";
const ENV_PATH = "/root/.hermes/.env";
// ---- аргументы ----
function parseArgs(argv) {
const a = {
target: null,
source: null,
html: null,
batch: false,
file: null,
out: null,
text: null,
list: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => argv[++i];
switch (arg) {
case "--target":
case "-t":
a.target = next();
break;
case "--source":
case "-s":
a.source = next();
break;
case "--html":
a.html = true;
break;
case "--no-html":
a.html = false;
break;
case "--batch":
a.batch = true;
break;
case "--file":
case "-f":
a.file = next();
break;
case "--out":
case "-o":
a.out = next();
break;
case "--text":
a.text = next();
break;
case "--list-languages":
case "--list":
a.list = true;
break;
default:
if (!a.text && !arg.startsWith("-")) a.text = arg;
}
}
return a;
}
// ---- ключ ----
function getKey() {
const fromEnv = process.env.HERMES_CUSTOM_APINEX_API_KEY || process.env.APINEX_API_KEY;
if (fromEnv) return fromEnv;
try {
const txt = readFileSync(ENV_PATH, "utf8");
const m = txt.match(/^\s*HERMES_CUSTOM_APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m)
|| txt.match(/^\s*APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m);
if (m) return m[1].trim();
} catch {}
throw new Error("API key not found (HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY / " + ENV_PATH + ")");
}
// ---- защита токенов, <script>, <style> и <code> для HTML ----
function protect(html) {
const store = [];
let out = html
// <script>/<style> целиком: переводчик не должен трогать JS/CSS
.replace(/<script[\s\S]*?<\/script>/gi, (m) => { store.push(m); return `<!--SCP${store.length - 1}-->`; })
.replace(/<style[\s\S]*?<\/style>/gi, (m) => { store.push(m); return `<!--SCP${store.length - 1}-->`; })
.replace(/\{\{[\s\S]*?\}\}/g, (m) => { store.push(m); return `<!--TPL${store.length - 1}-->`; })
.replace(/<code>([\s\S]*?)<\/code>/g, (_m, inner) => { store.push(inner); return `<code><!--CPL${store.length - 1}--></code>`; });
return { out, store };
}
function restore(html, store) {
return html
.replace(/<!--SCP(\d+)-->/g, (_m, i) => store[+i])
.replace(/<!--TPL(\d+)-->/g, (_m, i) => store[+i])
.replace(/<code><!--CPL(\d+)--><\/code>/g, (_m, i) => `<code>${store[+i]}</code>`);
}
// ---- сам перевод ----
async function translateTexts(texts, { target, source, html }) {
const body = {
text: texts,
target_lang: target,
preserve_formatting: true,
};
if (source) body.source_lang = source;
if (html) body.tag_handling = "html";
const res = await fetch(API_URL, {
method: "POST",
headers: {
Authorization: "Bearer " + getKey(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Translator HTTP ${res.status}: ${err}`);
}
const data = await res.json();
return data.translations.map((t) => t.text);
}
async function listLanguages() {
const res = await fetch(LANG_URL, { headers: { Authorization: "Bearer " + getKey() } });
if (!res.ok) throw new Error(`Languages HTTP ${res.status}`);
return res.json();
}
// ---- main ----
async function main() {
const a = parseArgs(process.argv.slice(2));
if (a.list) {
const langs = await listLanguages();
console.log(JSON.stringify(langs, null, 2));
return;
}
if (!a.target) {
console.error("Usage: translate.mjs --target EN [--source RU] [--html] [--file|-f path] [--text|-t str] [--batch] [--out|-o path]");
process.exit(2);
}
let raw;
if (a.file) raw = readFileSync(resolve(__dirname, "..", a.file), "utf8");
else if (a.text) raw = a.text;
else raw = readFileSync(0, "utf8"); // stdin
const useHtml = a.html ?? (raw.includes("<") && raw.includes(">"));
let texts;
if (a.batch) {
texts = raw.split(/\r?\n/).filter((l) => l.trim().length);
} else {
texts = [raw];
}
let results;
if (useHtml) {
const protectedTexts = texts.map((t) => protect(t));
const translated = await translateTexts(protectedTexts.map((p) => p.out), {
target: a.target,
source: a.source,
html: true,
});
results = translated.map((t, i) => restore(t, protectedTexts[i].store));
} else {
results = await translateTexts(texts, { target: a.target, source: a.source, html: false });
}
const output = a.batch ? results.join("\n") : results[0];
if (a.out) writeFileSync(resolve(__dirname, "..", a.out), output, "utf8");
else process.stdout.write(output + (a.batch ? "" : ""));
if (!a.out) process.stdout.write("\n");
}
main().catch((e) => {
console.error("ERROR:", e.message);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
{
"nav.aria": "Page sections",
"nav.projects": "Portfolio",
"nav.experience": "Experience",
"nav.skills": "Skills",
"nav.miniProjects": "Activity",
"nav.contact": "Contact",
"skip.home": "Skip to main content",
"skip.case": "Skip to case content",
"logo.aria": "Home",
"logo.text": "NM",
"more.sections": "More sections",
"back.home": "Home",
"footer.credit": "Website design and development — Nikolay Mamadaev",
"footer.designSystem": "Design System",
"footer.cookiePolicy": "Cookie policy",
"lang.aria": "Choose language",
"lang.ru": "RU",
"lang.en": "EN"
}
+20
View File
@@ -0,0 +1,20 @@
{
"nav.aria": "Разделы страницы",
"nav.projects": "Портфолио",
"nav.experience": "Опыт работы",
"nav.skills": "Навыки",
"nav.miniProjects": "Активность",
"nav.contact": "Контакты",
"skip.home": "К основному содержимому",
"skip.case": "К содержимому кейса",
"logo.aria": "На главную",
"logo.text": "НМ",
"more.sections": "Ещё разделы",
"back.home": "На главную",
"footer.credit": "Дизайн и разработка сайта — Николай Мамадаев",
"footer.designSystem": "Design System",
"footer.cookiePolicy": "Политика cookie",
"lang.aria": "Выбор языка",
"lang.ru": "RU",
"lang.en": "EN"
}
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: AI Assistant—a Figma plugin with AI features."
/>
<title>AI Assistant — Figma Plugin — Case Study</title>
{{> head-links}}
{{> head-plyr}}
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="ai-assistant">
{{> header-case}}
{{> fab-stack}}
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Figma · Plugin</p>
<h1 class="case-page__h1">AI Assistant</h1>
<p class="case-page__sub">A Figma plugin with AI features</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Context</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Task</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Solution</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h2 id="result">Result</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
{{> mini-projects-related}}
{{> contact-section}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
-1
View File
@@ -118,7 +118,6 @@
{{> mini-projects-related}}
{{> contact-section}}
{{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
@@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: The AI Server Interface in Primo RPA—OCR for Corporate Documents, Model Fine-Tuning, Skills, and UX/UI."
/>
<title>AI Server in Primo RPA — Case Study (Draft 2)</title>
{{> head-links}}
{{> head-plyr}}
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-block {
margin-top: 3rem;
padding-top: 2.5rem;
border-top: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.case-block:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body>
{{> header-case}}
{{> fab-stack}}
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Product Design · AI</p>
<h1 class="case-page__h1">User Interface Development for the Primo RPA AI Server</h1>
<p class="case-page__sub">
Intelligent document<br /> processing platform
and AI agent orchestration
</p>
<div class="case-block" id="obzor">
<h2>Overview</h2>
<p class="section__prose">
A service was launched within the company that significantly improved the workflow for text recognition models on documents: loading a dataset from company documents, field tagging, model training, and the subsequent use of the model in other products. This approach provided higher recognition quality and met security requirements—data remained within the company’s network. As work on the product progressed, functionality was added to fine-tune the base neural networks for the company’s specific tasks and to work with skills such as summarization, extraction, generation, and other scenarios.
</p>
<p class="section__prose">
I was invited to join the project as a UX/UI designer; prior to that, the developers had been building the interface on their own. As a result of this work, the service’s barrier to entry was lowered—significantly more people could use the product without specialized training in AI and OCR.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
The main benefit for the business wasn’t just a “pretty picture,” but the ability to scale the solution: less reliance on a narrow circle of experts and an implementation team at every step.
</p>
</div>
</aside>
</div>
<div class="case-block" id="bylo-stalo">
<h2>Before and After</h2>
<p class="section__prose">
The service was built using <a href="https://ant.design/components/overview" target="_blank" rel="noopener noreferrer">Ant Design</a> on top of React with the <a href="https://templates.iqonic.design/hope-ui/html/dist/" target="_blank" rel="noopener noreferrer">Hope UI</a> visual theme for Bootstrap. The company already had several web products and wanted to unify and refresh the user interface, so the development team relied on components from those systems.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji" aria-hidden="true">⚠️</span>
<div class="case-callout__content">
<p>
The main pain point was project configuration: many steps and small tasks, with three types of projects requiring different configuration scenarios. It took a long time to go from creating a project to actually using it, and assistance from the implementation team was often required. Confusing navigation and duplicated functionality reduced productivity and prevented us from reaching a wide audience.
</p>
</div>
</aside>
<p class="section__prose">
I immersed myself in the product and, together with the team, analyzed all the functionality in detail.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✨</span>
<div class="case-callout__content">
<p>
<strong>My experience with AI</strong> and web platforms with similar logic greatly accelerated the work: it was easier to stay in sync with the development team and not get lost in the domain terminology.
</p>
</div>
</aside>
<p class="section__prose">
I mapped out user flows based on current scenarios: these showed the team where confusion arose in the interface and user journeys. Based on this, we redesigned the configuration processes themselves: we automated and hid some functions to reduce the risk of errors and eliminate unnecessary routine tasks.
</p>
<p class="section__prose">
The new approach changed both the perception of the product and the setup process. The interface guides users through the setup steps without disrupting their usual workflows: a fully linear wizard isn’t feasible here—users still need to go back and refine settings to ensure high-quality results.
</p>
<h3>Questions for the team regarding the current status</h3>
<p class="section__prose">The answers will help quantify the impact of the implementation with facts and figures.</p>
<ul>
<li>Has the number of support requests decreased?</li>
<li>Are users able to complete project setup on their own from start to finish?</li>
<li>Does the new interface allow users to create new types of projects based on existing patterns?</li>
</ul>
</div>
<div class="case-block" id="patterns">
<h2>Interface Patterns</h2>
<p class="section__prose">
Below are the key areas of the product: how they support learning and model-working scenarios, and how they differ from the previous approach.
</p>
<h3>Field Mapping</h3>
<p class="section__prose">
Mapping links document fields to the target data structure. It was important to make the relationship visual and reversible so that users don’t lose context when making edits.
</p>
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video
controls
preload="metadata"
playsinline
poster="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.webp"
data-case-video-player
style="display: block; width: 100%; height: auto; margin: 0"
>
<source src="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.webm" type="video/webm" />
<source src="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.mp4" type="video/mp4" />
Your browser does not support video.
</video>
</div>
<figcaption>Field Mapping in the Interface</figcaption>
</figure>
<h3>Dataset, Training, and Quality Control</h3>
<p class="section__prose">
The dataset editor, training launch, and quality screens form a single mental path: data preparation → training → result verification.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji" aria-hidden="true">➡️</span>
<div class="case-callout__content">
<p>
Below are “before and after” screenshots of the dataset editor—they show how we simplified the visual hierarchy and key actions on the screen.
</p>
</div>
</aside>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset-editor.webp"
alt="Dataset Editor — Current Version of the Interface"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Dataset Editor—Current Version</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset-editor-old.webp"
alt="Dataset Editor — Previous Version for Comparison"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Previous version of the editor</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset-lerning.webp"
alt="Model Training Screen Using a Dataset"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Training on a dataset</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset-quality.webp"
alt="Quality Control of the Model After Training"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Quality and result verification</figcaption>
</figure>
<h3>Inference in the project</h3>
<p class="section__prose">
Special Focus: Running and Monitoring the Model Within the Project: The user must understand the status of the inference run and stay in sync with the rest of the settings.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in-project-inference.webp"
alt="Model inference interface within the project"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Inference in the project</figcaption>
</figure>
<figure class="case-media" data-case-tall-media data-case-tall-width="wide">
<div class="case-media-tall" data-case-tall-root>
<div class="case-media-tall__clip" id="ai-case-tall-inference-full" data-case-tall-clip>
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in-project-inference-full.webp"
alt="Inference in the project — full-screen interface"
width="1200"
loading="lazy"
decoding="async"
data-image-viewer
/>
<div class="case-media-tall__fog" aria-hidden="true"></div>
</div>
<div class="case-media-tall__actions">
<button
type="button"
class="case-media-tall__btn btn btn--ghost btn--flat"
data-case-tall-toggle
aria-expanded="false"
aria-controls="ai-case-tall-inference-full"
><span class="case-media-tall__btn-chev" aria-hidden="true"></span>
<span data-case-tall-label-more>Expand</span>
<span data-case-tall-label-less hidden>Collapse</span>
</button>
</div>
</div>
<figcaption>Full vertical screenshot of the inference screen</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in-project-inference-old.webp"
alt="Previous version of the inference screen in the project"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Previous version of inference</figcaption>
</figure>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">⭐</span>
<div class="case-callout__content">
<p>
Full-screen mode and a separate import window isolate tasks from the main project context: you can focus on the process or, conversely, keep the panels handy—see the three layout options below.
</p>
</div>
</aside>
<h3>Full-Screen Mode and Import</h3>
<p class="section__prose">
This isn’t about “long tasks,” but rather about processes in a separate context: some block work on the project until they’re finished, while others run in parallel in a separate window, allowing you to continue configuring project settings. We moved this functionality to a separate window because there were several entry points for these tasks, and a standard modal wasn’t enough—you had to complete a separate process elsewhere and then return to the project settings. This approach reduced the amount of development and testing required.
</p>
<p class="section__prose">
Below are three full-screen canvas options: import in a separate window, panel mode, and the minimalist “zen” mode.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/fullscreen_modal-import_dataset.webp"
alt="A modal window for importing a dataset on a separate screen"
width="1440"
height="1024"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Import in a modal window</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/full-screen-mode-with-panels.webp"
alt="Full-screen mode with side panels"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Full-screen mode with panels</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/full-screen-mode-zen.webp"
alt="Full-screen mode without distracting panels"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Minimal mode without unnecessary chrome</figcaption>
</figure>
<h3>Future Development</h3>
<p class="section__prose">
Several major items remained on the roadmap: scenarios related to the LLM (lists, statuses, actions), a step-by-step training wizard with progress tracking and undo functionality, and a full-featured OCR engine with region marking and on-page quality previews. These were not included in the minimum set for the interface’s first production release, but the established patterns should accommodate them without major rework.
</p>
</div>
<div class="case-block" id="process">
<h2>Process and Handoff</h2>
<p class="section__prose">
Work proceeded through regular synchronization with the development team: mockups, specifications, and a phased handoff to the front end. After I left the project, the team looked for the next designer and structured the onboarding process based on the existing design assets and layout guidelines.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
For the next designer, the most important thing is to rely on the user flow and project configuration decisions—that’s where the main complexity of the domain lies.
</p>
</div>
</aside>
<h3>Questions for the Next Designer</h3>
<ul>
<li>What turned out to be unclear during the first two weeks on the project?</li>
<li>Which artifacts and handover guidelines proved most useful?</li>
<li>What had to be redesigned and why.</li>
<li>How would you describe the current state of UX in three sentences?</li>
</ul>
</div>
<div class="case-block" id="zaklyuchenie">
<h2>Conclusion</h2>
<p class="section__prose">
The AI Server interface in Primo RPA was transformed from an “engineer-oriented developer interface” into a product with intuitive configuration logic and user guidance. The focus on user flows and routine automation reduced the implementation burden and paved the way for broader adoption within the company and among clients.
</p>
</div>
</div>
</main>
{{> projects-related}}{{> projects-section}}
{{> contact-section}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
</body>
</html>
@@ -414,7 +414,6 @@
{{> projects-related}}{{> projects-section}}
{{> contact-section}}
{{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
@@ -0,0 +1,551 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Case Study: Redesign of the Primo RPA AI Server — Lowering the Barrier to Entry for Customers Without an Implementation Team."
/>
<title>AI Server in Primo RPA — Case Study</title>
{{> head-links}}
{{> head-plyr}}
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.7rem, 3vw, 2rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.55rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
font-size: 1.1rem;
line-height: 1.7;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
font-size: 1.1rem;
line-height: 1.7;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 1.25rem 0 1.5rem;
font-size: 1.05rem;
line-height: 1.6;
color: var(--text-muted);
}
.case-meta dt {
font-weight: 600;
color: var(--text);
}
.case-meta dd {
margin: 0;
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body>
{{> header-case}}
{{> fab-stack}}
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Product Design · AI</p>
<h1 class="case-page__h1">Simplified Primo RPA AI Server setup: customers configure the AI service without an implementation team</h1>
<p class="case-page__sub">
A platform for intelligent document<br />
processing and AI agent orchestration. Requests to the implementation team have dropped by 40%, and the time required to set up the first project has been reduced from ~3 days to ~4 hours.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/main-new.webp"
alt="The updated dataset markup editor—the service's main screen following the redesign"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
</figure>
<dl class="case-meta">
<dt>Product</dt>
<dd>AI Server. Neural networks within the company’s internal network, configured via a web interface</dd>
<dt>Role</dt>
<dd>UX/UI Designer (the only one on the project)</dd>
<dt>Objective</dt>
<dd>Lower the barrier to entry so that the client can configure the service themselves</dd>
<dt>Stack</dt>
<dd>Ant Design + React, styled with Hope UI</dd>
<dt>Format</dt>
<dd>Redesign of a live product; releases continued uninterrupted</dd>
</dl>
<div class="case-block" id="problem">
<h2>Only experts could handle the configuration. This slowed down sales</h2>
<p class="section__prose">
The AI Server is deployed on the client’s internal servers. The product was actively tested, and the feedback revealed an unpleasant truth: only experts and the Primo RPA implementation team could configure it. Every new client consumed hours of technical support. The product was losing its appeal and didn’t scale well.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
Business goal: The client sets up AI Server on their own, without technical support or an implementation team. We measured the success of the redesign based on this criterion.
</p>
</div>
</aside>
<p class="section__prose">
I started by getting to know the team and the configuration experts. I divided the scenarios into primary and secondary ones, created a visual representation of user behavior, and worked with the development team to identify areas where the interface could be simplified. I had already worked with AI and similar web platforms, so I quickly got up to speed on the subject matter and was able to speak the same language as the development team.
</p>
</div>
<div class="case-block" id="constraints">
<h2>Constraints</h2>
<ul>
<li>Customers were already using the product. It wasn’t possible to halt development and completely rebuild the interface.</li>
<li>Changes had to be incorporated into the release plan; no one allocated time for a separate “redesign sprint.”</li>
<li>Strict tech stack: Ant Design on top of React, styled after Hope UI. We only created new components as a last resort; first, we made the most of the existing library.</li>
</ul>
</div>
<div class="case-block" id="navigation">
<h2>Navigation was confusing: project sections were mixed in with the main menu</h2>
<p class="section__prose">
The service used a layout common to admin panels: a sidebar on the left, content on the right. Space was wasted, and when opening a project, navigation through its sections was intermixed with the main navigation. Users got lost.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
Having two navigation systems in a single sidebar forced users to figure out “where they were” every time. I organized them by level: the main navigation at the top, and project-specific navigation within each project.
</p>
</div>
</aside>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/main-new.webp"
alt="The AI Server home screen with a compact top navigation bar"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>The AI Server home screen with a compact top navigation bar—the list of projects takes up almost the entire width of the screen</figcaption>
</figure>
<p class="section__prose">
I moved the main navigation to the top and condensed it. The content now has noticeably more space, while the layout remains familiar. Projects now have their own navigation that doesn’t overlap with the main navigation.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/nav-project-nlp.webp"
alt="Internal Navigation of the NLP Project"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Internal navigation for the NLP project—horizontal step-by-step items below the main menu</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/nav-project-ocr.webp"
alt="Internal Navigation for the OCR Project"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Internal navigation for the OCR project—the same pattern with its own set of sections</figcaption>
</figure>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✨</span>
<div class="case-callout__content">
<p>
The arrow-shaped icons suggest a configuration order but do not enforce it: the sections remain standard tabs. A suggestion, not a requirement.
</p>
</div>
</aside>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/nav-secondary-detail_view.webp"
alt="Close-up of secondary navigation—arrow-shaped markers"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Close-up of secondary navigation—items in the form of arrows pointing from left to right</figcaption>
</figure>
</div>
<div class="case-block" id="editor">
<h2>Dataset Editor: the screen where users spend most of their time</h2>
<p class="section__prose">
This is where users spend most of their time. Annotating fields on hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor didn’t fit the overall style, looked cluttered, and made working with fields confusing.
</p>
<p class="section__prose">
I broke the editor down into microscenarios, organized them by priority and interdependencies. Together with the team, we designed a smoother and more intuitive user experience, reserved space for future features, and aligned the design with the service’s overall style.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset-editor.webp"
alt="Dataset Markup Editor — the document being marked up in the center, with field and tool panels on either side"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Dataset markup editor—the document being marked up is in the center, with field and tool panels on either side</figcaption>
</figure>
<p class="section__prose">
The page became symmetrical. Almost all the space is devoted to the image being annotated, and actions are grouped by panels: the desired function can be found quickly.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset_editor-wireframe-default_mode.webp"
alt="A schematic overview of the editor's layout—the central workspace and side panels"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Schematic layout of the editor—central workspace and side panels, default mode</figcaption>
</figure>
<p class="section__prose">
For advanced users, I’ve added a “Full Screen” mode. The side panels collapse, and you work using keyboard shortcuts and the bottom panel. This is a lifesaver on small monitors.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/full-screen-mode-with-panels.webp"
alt="Full-screen editor mode with side panels open"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Full-screen editor mode with side panels expanded</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/full-screen-mode-zen.webp"
alt="Full-screen mode without panels—only the document being marked up and the bottom panel"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Full-screen mode without panels—only the document being edited and the bottom toolbar are visible on the screen</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-both_panels.webp"
alt="Four Full-Screen Mode Frame Options"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Four full-screen layout options: both panels, no panels, left panel only, right panel only</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/dataset_editor-main_toolbar.webp"
alt="Bottom toolbar — switch between images and quick markup actions"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Bottom toolbar—switch between images and quick markup actions</figcaption>
</figure>
</div>
<div class="case-block" id="mapping">
<h2>Dozens of fields on a single scan, without sacrificing document space</h2>
<p class="section__prose">
Related task: Configure unique field settings for the dataset. A large area is needed for the document scan, with dozens of fields, each having its own types and dependent settings.
</p>
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video
controls
preload="metadata"
playsinline
poster="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.webp"
data-case-video-player
style="display: block; width: 100%; height: auto; margin: 0"
>
<source src="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.webm" type="video/webm" />
<source src="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.mp4" type="video/mp4" />
Your browser does not support video.
</video>
</div>
<figcaption>Field mapping prototype—the user selects a field on the document scan and configures its parameters in a compact panel</figcaption>
</figure>
<p class="section__prose">
The interface turned out to be compact. The mechanics aren’t immediately obvious at first glance, but after a short learning curve, working with it becomes quick and comfortable.
</p>
</div>
<div class="case-block" id="learning">
<h2>Training cards: a pattern that has spread throughout the product</h2>
<p class="section__prose">
After annotating the dataset, the model undergoes fine-tuning. We analyzed all user actions and explored several page layouts. We settled on a card feed.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in_project-learn-overview.webp"
alt="Model Training Section — Vertical Card Strip"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>The model training section is a vertical card feed, with a new training setup card at the top</figcaption>
</figure>
<p class="section__prose">
At the beginning of the feed is a configuration card: the annotation results for the active dataset, parameters, and the option to start fine-tuning.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in_project-learn-card-new.webp"
alt="Training Configuration Card — Dataset Quality Matrix and Run Parameters Block"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Training configuration card—dataset quality matrix and launch parameters section</figcaption>
</figure>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">⭐</span>
<div class="case-callout__content">
<p>
The annotation quality matrix shows the dataset’s readiness for training on a single screen. Previously, we had to manually review fields across all documents.
</p>
</div>
</aside>
<p class="section__prose">
A separate card appears for each running process. You can start multiple processes with different parameters and compare which configuration performed better.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in_project-learn-card-learning_process.webp"
alt="Active Learning Process Card — Status, Progress, and Metrics"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Card for a running training process—status, progress, and metrics organized into logical blocks</figcaption>
</figure>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in_project-learn-card-learning_process_wireframe.webp"
alt="Learning Process Card Template"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>The framework of a training process card—a layout of information blocks and actions</figcaption>
</figure>
<p class="section__prose">
Cards can be collapsed; the title and status remain visible in the header.
</p>
<figure class="case-media case-media--natural">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/in_project-learn-card-сollapsed.webp"
alt="A collapsed training card—a single line showing the name and status of the process"
width="1200"
height="675"
loading="lazy"
decoding="async"
/>
<figcaption>Collapsed training card—a single line showing the process name and status</figcaption>
</figure>
<p class="section__prose">
Patterns from these cards were later applied to other sections of the product. It became easier to maintain a consistent style.
</p>
</div>
<div class="case-block" id="processes">
<h2>Long processes—in a separate tab</h2>
<p class="section__prose">
Some tasks run in parallel with other work. For such scenarios, a modal window opens on a separate page.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
The user keeps the process window open in a neighboring tab to monitor progress while continuing to work on the project in the available sections.
</p>
</div>
</aside>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/fullscreen_modal-import_dataset.webp"
alt="Importing a dataset in a modal window on a separate page"
width="1440"
height="1024"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Importing a dataset in a modal window on a separate page—a full-screen upload form</figcaption>
</figure>
<p class="section__prose">
Meanwhile, a placeholder with a button to navigate to the process remains on the project page.
</p>
<figure class="case-media case-media--wide">
<img
src="{{assetPrefix}}assets/ai-platform-primo-rpa/fullscreen_modal-in_project-import_dataset.webp"
alt="Active Import Placeholder Within a Project"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Placeholder for active import within the project—progress indicator and &quot;Go to Process&quot; button</figcaption>
</figure>
</div>
<div class="case-block" id="decisions">
<h2>What I Decided Not to Do, and Why</h2>
<ul>
<li><strong>I chose not to disable buttons on individual pages</strong> during active processes. A modal on a separate page is technically simpler, and progress is visible in the adjacent tab.</li>
<li><strong>I didn’t create new components</strong> except in extreme cases. The ready-made Ant Design library sped up development and kept the interface consistent.</li>
<li><strong>I didn’t remove the editor’s side panels entirely</strong>; instead, I made them collapsible. Beginners need on-screen hints, while advanced users need as much space as possible for their document.</li>
<li><strong>I didn’t set up a separate design system.</strong> The product already had a framework, so I passed links to the existing user flows in Figma to the development team.</li>
</ul>
<h3>Handoff to Development</h3>
<p class="section__prose">
Once a week, we discussed the design with the team: the task list, priorities, and deadlines aligned with release plans. I went over the details of the mockups one-on-one with the front-end developer and provided feedback after the layout was completed to ensure the result matched the intended behavior.
</p>
</div>
<div class="case-block" id="results">
<h2>Result</h2>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📈</span>
<div class="case-callout__content">
<p>
Setup for the first project: it used to take ~3 days, now it takes ~4 hours. 7 out of 10 customers complete the setup without technical support.
</p>
</div>
</aside>
<ul>
<li>Requests to the implementation team during the setup phase decreased by <strong>40%.</strong> Clients complete the basic setup on their own.</li>
<li><strong>7 out of 10</strong> new clients set up their first project without technical support. Before the redesign, it was 1 out of 10.</li>
<li>Setting up the first project: from <strong>~3 days to ~4 hours</strong>.</li>
<li><strong>Over 30 screens</strong> were redesigned: navigation, dataset editor, model training, and data import.</li>
<li>We established design patterns that allow functionality to be expanded without major rework.</li>
<li>The company hired a second designer. I brought her up to speed and oversaw the results; after she became familiar with the project, it was entirely transferred to her responsibility.</li>
</ul>
</div>
<div class="case-block" id="conclusions">
<h2>Conclusions</h2>
<ul>
<li>The rigid framework turned out to be an accelerator rather than a limitation: the ready-made component library allowed us to focus on scenarios rather than pixels.</li>
<li>The most valuable insights came from configuration experts. Next time, I would involve the implementation team even earlier.</li>
<li>Handing the project over to a new designer confirmed that the established patterns work and can be explained and maintained without the original author.</li>
</ul>
</div>
</div>
</main>
{{> projects-related}}{{> projects-section}}
{{> contact-section}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
</body>
</html>
@@ -544,7 +544,6 @@
{{> projects-related}}{{> projects-section}}
{{> contact-section}}
{{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more