Author SHA1 Message Date
Nikolay Mamadaev e5bead9104 feat(fonts): preset Sans uses Medeo Sans (replaces Geologica + PT Sans) 2026-10-29 18:20:45 +02:00
Nikolay Mamadaev cd5aefbb14 fix: load Umami analytics only on production domain 2026-10-19 12:39:34 +02:00
Nikolay Mamadaev a8346c2ec7 fix: keep rotating value on one line 2026-10-18 20:14:44 +02:00
Nikolay Mamadaev 3e6ff48dac fix: format homepage rotating value proposition 2026-10-18 20:12:35 +02:00
Nikolay Mamadaev e19485e288 fix: stabilize rotating homepage value proposition 2026-10-18 20:10:27 +02:00
Nikolay Mamadaev a30e0e21d9 content: update homepage business value proposition 2026-10-18 20:05:42 +02:00
Nikolay Mamadaev 288435baea content: refine Primo RPA case title 2026-10-18 14:55:10 +02:00
Nikolay Mamadaev 8a1e775315 content: refine Primo RPA case heading 2026-10-18 14:33:14 +02:00
Nikolay Mamadaev ce13814732 content: apply Primo RPA case edits 2026-10-18 14:20:42 +02:00
Nikolay Mamadaev fb867596cc feat: add right-image horizontal cards 2026-10-15 17:14:49 +02:00
Nikolay Mamadaev 981f4b67af fix: correct showcase image paths 2026-10-15 17:05:15 +02:00
Nikolay Mamadaev 161f2efd13 fix: clear image placeholder after failed load 2026-10-12 14:00:57 +02:00
Nikolay Mamadaev 1d19ea0eb8 feat: add Umami analytics tracking 2026-10-07 20:28:12 +02:00
Nikolay Mamadaev f4404edaca design: redesign ai-dev with expressive type and project grid 2026-10-07 19:57:40 +02:00
Nikolay Mamadaev be7da3d0ec content: инфраструктурные проекты и раздел об инфраструктуре на /ai-dev/ 2026-10-07 16:04:04 +02:00
Nikolay Mamadaev f383ec350a feat: llms.txt — карта сайта для LLM-агентов + link-relation describedby в head
Генерируется в build-pages.mjs из тех же страниц, что и sitemap.xml, с ручными описаниями по каждому URL. Страницы-каталоги дизайн-системы (noindex) в файл не попадают.
2026-10-07 14:01:09 +02:00
Nikolay Mamadaev ee0dca11a2 feat: компонент фильтр-чипов в дизайн-систему + фильтр проектов на /ai-dev/ 2026-10-07 13:05:10 +02:00
Nikolay Mamadaev 52485a17c4 feat: страница /ai-dev/ — AI-разработка и проекты, карточка в Активности 2026-10-07 12:52:10 +02:00
root 39dad71bd1 refactor: убрать стеклянный слой со всех карточек портфолио и активности через data-glass="false" 2026-10-05 19:10:24 +02:00
root bd0e562934 refactor: убрать стеклянный слой со всех карточек портфолио и активности через data-glass="false"
CSS: .mp-card[data-glass="false"] ::before/::after { display: none }, .portfolio-card[data-glass="false"] background -> var(--surface)
2026-10-05 19:08:12 +02:00
root 90b77e4433 refactor: убрать стеклянный слой со всех карточек через data-glass="false" 2026-10-05 19:06:51 +02:00
root c2ee622680 refactor: убрать стеклянный слой с карточек портфолио через параметр data-glass="false" 2026-10-05 17:53:19 +02:00
root 276c447567 perf: пауза шейдера на главной при скролле >1000px 2026-10-05 15:19:29 +02:00
root 8eeeb9eb0a content: обновить обложку Primo AI на главной 2026-10-04 14:45:10 +02:00
root f2bd745b70 content: заменить основной Primo AI Server кейс на STAR 2026-10-04 14:19:55 +02:00
root 6481fff41d content: уточнить использование обученной AI-модели в Primo 2026-10-04 14:08:11 +02:00
root caa08c8ad7 content: убрать сетку метаданных (Роль/Продукт/Платформа) из вводной кейса STAR 2026-10-04 12:24:35 +02:00
root 4e17471469 content: обложка кейса STAR — дашборд Primo AI Server в секции Результат + og:image 2026-10-04 11:51:09 +02:00
root d8c9401381 content: правки по кейсу STAR из content-edits + доска сценариев 2026-10-02 19:48:36 +02:00
root ac1eceefd1 feat: кейс Primo AI Server по фреймворку STAR (страница без ссылок на сайте, noindex, только ru) 2026-10-02 16:21:03 +02:00
root a8ae6323c0 content: в en-резюме секция About Me перед Work Experience; «·» вынесен из ссылки 2026-10-02 13:44:11 +02:00
root 1500849eb6 content: уточнён абзац про UI-kit в кейсе AI-платформы Primo RPA 2026-10-01 21:56:11 +02:00
root 1f2ecaf415 feat: уменьшить прозрачность акцентного tint до 0.10 2026-10-01 19:52:54 +02:00
root 90c012d580 feat: градиент тонирования акцентом (снизу акцент → кверху прозрачно) 2026-10-01 19:23:22 +02:00
root e0f80d074d feat: тонирование акцентом на стеклянном слое (--mp-card-glass-tint) 2026-10-01 19:16:58 +02:00
root 3aced0387b refactor: стекло только на .mp-card--stack-bottom (убрать с остальных) 2026-10-01 18:45:32 +02:00
root 4c2d496343 fix: нормальное превью (card-demo.webp 800x450) в демо-карточках 2026-10-01 14:42:42 +02:00
root ee9aafbd99 feat: демо стеклянного слоя (.mp-card--glass) на Mini Projects Showcase 2026-10-01 14:35:54 +02:00
root 1695ad504d feat: демо стеклянного слоя (.mp-card--glass) на Design System 2026-10-01 14:28:00 +02:00
root 207e6121aa fix: резюме подхватывает акцент темы из localStorage (формат light/dark) 2026-10-01 14:22:13 +02:00
root 9fc2dcb02e style: убрать верхние скругления у glass-слоя 2026-09-29 21:30:44 +02:00
root 1891a99ef1 feat: glass-слой на связанных карточках кейсов (mp-card--glass) 2026-09-29 21:11:12 +02:00
root dab79048f1 feat: опциональный glass-слой .mp-card--glass (только #portfolio) 2026-09-29 21:04:44 +02:00
root 61230de426 style: mp-card glass-слой 1/3 высоты снизу 2026-09-29 20:59:53 +02:00
root 93afa3814f style: стеклянный слой на .mp-card в #portfolio (токены меню) 2026-09-29 20:45:51 +02:00
root 8637ee6024 style: portfolio-card glass-слой на токенах меню (.glass-neutral) 2026-09-29 20:05:17 +02:00
root 5b43d486c0 style: добавить стеклянный слой поверх portfolio-card 2026-09-29 19:58:54 +02:00
root ac8b60049a content: применить правки кейса Card Columns 2026-09-29 18:29:44 +02:00
root 908d5b259d Merge remote-tracking branch 'origin/main' into dev 2026-09-29 16:49:20 +02:00
root 6325186f52 edit: вычитай кейс Card Columns 2026-09-29 16:47:05 +02:00
root eb90714012 feat: добавь скринкасты Card Columns и примени обрезку 2026-09-29 15:04:27 +02:00
root 8b50c821a7 feat: перепиши кейс Card Columns без черновиков 2026-09-29 14:48:53 +02:00
root d25c2e2185 feat: добавь ссылку и скриншоты Card Columns в кейс 2026-09-29 14:06:18 +02:00
root 0f8277dd0a content(site-portfolio): rewrite for readability and flow 2026-09-28 20:37:13 +02:00
root 052b5c4829 content(site-portfolio): apply queued deployment edits 2026-09-28 20:17:25 +02:00
root d41059d720 content(site-portfolio): document deployment workflow 2026-09-28 19:23:31 +02:00
root f702423445 Merge branch 'dev' into main (mesh-shader bfcache fix) 2026-09-28 15:50:51 +02:00
root 8a63190f5a fix(mesh-shader): re-init shader after bfcache restore (back/forward nav) 2026-09-28 15:47:21 +02:00
root cd118c534f Merge remote-tracking branch 'origin/dev' into deploy/site-portfolio-main 2026-09-28 15:35:41 +02:00
root adf0f4b052 content(site-portfolio): refine headings and callouts 2026-09-28 15:25:47 +02:00
root 02f05c0598 content(site-portfolio): draft personal LLM workflow case 2026-09-28 15:17:47 +02:00
root a9957e739f content(site-portfolio): add current status 2026-09-28 15:07:12 +02:00
root 3a4537209b content: rename site case to site-portfolio 2026-09-28 14:18:20 +02:00
root fd8e524dba Merge branch 'dev' 2026-09-28 13:07:38 +02:00
root 457fff314b fix(nav): place activity after portfolio 2026-09-28 13:07:19 +02:00
root 799878c891 Merge branch 'dev' 2026-09-28 12:51:07 +02:00
root c8a9c82bfa content(ai-assistant-figma): сократил местоимения 2026-09-26 21:35:52 +02:00
root 03a8e73ea6 content(ai-assistant-figma): callout и распределение медиа 2026-09-26 19:48:39 +02:00
root a77033e71f content(ai-assistant-figma): дополнил кейс 2026-09-26 19:20:05 +02:00
root 3388268697 feat(home): секция Активность под Портфолио + активные кейсы впереди 2026-09-26 11:17:37 +02:00
root 9608e24ae8 style(nav): увеличил размер шрифта главного меню 2026-09-26 11:08:59 +02:00
root 2267a35698 style(nav): обычный шрифт (400) и крупнее в главном меню 2026-09-26 11:07:50 +02:00
root 9f617c581a chore: add domain verification file b5f15001666297c4700dec311e28ef6269bf8b67.txt 2026-09-25 17:29:08 +02:00
root a692278267 chore: add domain verification file b5f15001666297c4700dec311e28ef6269bf8b67.txt 2026-09-25 17:26:36 +02:00
root 62c0a8f044 feat(ai-assistant-figma): добавил третье видео (запись экрана, WebM+poster) 2026-09-25 12:02:39 +02:00
root 8c34d3fa97 fix(video): поддержка Range на dev-сервере + Cues в начале webm (seek в плеере) 2026-09-25 00:25:34 +02:00
root 3d5fbe45ee feat(ai-assistant-figma): добавил второе демо-видео (WebM+poster) 2026-09-24 23:12:53 +02:00
root 61589176b2 feat(ai-assistant-figma): добавил демо-видео (WebM+poster) в секцию Решение 2026-09-24 22:53:54 +02:00
root 09111a946c fix(ai-assistant-figma): реальный размер картинок (case-media--real вместо wide) 2026-09-24 22:40:58 +02:00
root 57146ec64a fix(ai-assistant-figma): включил data-case-sequential-images (подстановка src) + картинки в en-версию 2026-09-24 22:14:48 +02:00
root 0baf415e13 feat(ai-assistant-figma): добавил 6 скриншотов плагина (WebP) в секцию Решение 2026-09-24 22:06:13 +02:00
root 69c9223401 fix(resume-pdf): не удалять шапку резюме (resume-header) при генерации 2026-09-24 21:57:42 +02:00
root 4b2fe4fe9c fix(resume-pdf): --no-sandbox для запуска под root + regen PDF 2026-09-24 21:53:31 +02:00
root 6d064e53b9 feat(hero): fade-in hero после готовности фона-шэйдера
- .hero стартует скрытым, проявляется при html.mesh-bg-ready
- markMeshReady ставится во всех ветках (успех/ошибка/reduced-motion/нет хоста)
- reduced-motion: hero виден сразу без анимации
2026-09-24 21:42:41 +02:00
root daa623fe73 feat(mesh-bg): fade-in шейдера фона + modulepreload графа
- canvas стартует с opacity:0 и плавно проявляется (0.9s) при .is-ready
- initPageMeshShader ставит is-ready после первого кадра
- modulepreload page-mesh-shader.js для параллельной загрузки с main.js
- пересборка страниц (build:pages)
2026-09-24 21:37:40 +02:00
root b38f1a5332 Merge feature/liquid-glass: Liquid Glass эффект (displacement + матирование)
# Conflicts:
#	Dockerfile
2026-09-24 21:29:53 +02:00
root 11e24da9e2 fix(glass): бордюр удалён полностью (border: none), только внутренняя тень 2026-09-24 21:26:36 +02:00
root 903d222324 style(glass): убран видимый бордер, оставлена только внутренняя тень
- border-color: transparent на glass-блоках (геометрия/радиус сохранены)
- inset-тень в --v12-glass-neutral-shadow даёт эффект 'стеклянного края'
2026-09-24 21:01:49 +02:00
root e410a2975f revert(glass): полностью убрана градиентная обводка
- удалены ::after, isolation, токены -border-grad, transparent border
- возвращён исходный border-color: var(--v12-glass-neutral-border) / var(--line)
2026-09-24 18:42:22 +02:00
root 372ccfbf60 fix(glass): внешняя обводка через ::after + isolation:isolate (z-index:-1)
- inset:-1px выносит рамку наружу, isolation создаёт стек без сдвига layout
- position:relative только на не-fixed блоках; шапка (fixed) без правок
- z-index:-1 => под контентом, не перекрывает; backdrop-filter убран (не рендерился с mask)
2026-09-24 18:36:19 +02:00
root c69fc8f9c0 fix(glass): откат к безопасным background-слоям (без псевдоэлементов/z-index)
- убраны все ::after/position/z-index эксперименты, ломавшие раскладку
- градиентная обводка через 2 background-слоя (padding-box/border-box) — контент поверх, не ломает
- тёмная тема: засвет приглушён (0.4)
2026-09-24 18:14:42 +02:00
root 14b7cd829b fix(glass): внешняя обводка через ::after z-index:-1 без isolation
- z-index:0 на не-fixed родителях создаёт стек, -1 виден снаружи, контент не тронут
- шапка (fixed) без z-index:0, чтобы не опуститься ниже FAB
- убран backdrop-filter на псевдоэлементе (не рендерился с mask)
2026-09-24 18:06:30 +02:00
root 220fb634fc fix(glass): внешняя обводка через ::after с blur края (не перекрывает контент)
- inset:-1px выносит рамку наружу, z-index:-1 + isolation, backdrop-filter blur(3px) на кромке
- тёмная тема: засвет приглушён (0.55->0.4), бордер блюрит край как стекло
- убран background-слой
2026-09-24 17:58:53 +02:00
root df2ef8b7b6 fix(glass): градиентный бордер через background-слои вместо ::before mask
- убран псевдоэлемент, который перекрывал контент (меню/панели исчезали)
- градиент бордера теперь 2 слоя background: padding-box (фон) + border-box (градиент)
- контент снова виден, border-radius сохранён
2026-09-24 17:39:37 +02:00
root 6aeb5a378f feat(glass): градиентный бордер — сверху белый засвет -> снизу прозрачно
- токен --v12-glass-neutral-border-grad (dark/light)
- mask-псевдоэлемент ::before рисует 1px градиентный бордер с сохранением border-radius
- нативный border-color -> transparent у glass-блоков, шапки, nav-more, floating
2026-09-24 16:14:53 +02:00
root 94de132cf0 style(glass): лёгкое матирование поверх искажения
- панели: url(#displacement) blur(3px)
- кнопки: url(#displacement-btn) blur(2px)
- видна волна стекла + мягкое стекло, без глухой матовости
2026-09-24 15:38:37 +02:00
root 15bf0e3293 fix(glass): рябь -> плавные волны — низкочастотная карта смещений
- displacement.png: вместо per-pixel шума — крупные блобы (8px+20px октавы, гаусс), плавные градиенты
- scale снижен 28->14 (панели), 12->6 (кнопки) под крупные пятна
2026-09-24 15:30:00 +02:00
root 449d5ac782 style(glass): убрана матовость — чистое искажение фона (без blur)
- glass-панели: --v12-glass-neutral-filter-lg = url(#liquid-glass-displacement) без blur
- кнопки: отдельный фильтр #liquid-glass-displacement-btn (scale=12), blur убран
- видно само искажение стекла, как просил пользователь
2026-09-24 15:26:29 +02:00
root 2e6451e683 feat(glass): displacement-стекло на кнопках (btn--primary/fab--primary/secondary/ghost)
- primary/fab: полупрозрачный акцентный фон (--btn-primary-bg-lg/-hover-bg-lg, ~82-86%), иначе displacement не виден
- токены --btn-primary-backdrop-lg / -hover-lg = url(#filter) blur()+saturate()
- secondary/ghost: displacement через --v12-glass-neutral-filter-lg
- всё в @supports not (hanging-punctuation:first) — Chromium-only, Safari/Firefox держат чистый blur
2026-09-24 15:20:34 +02:00
root 5fd8324b47 feat(glass): Liquid Glass эффект через SVG displacement-фильтр (по статье Хабра)
- PNG карта мелкого равномерного шума (public/assets/liquid-glass/displacement.png)
- инлайн SVG feDisplacementMap инъецируется во все страницы сборщиком (build-pages.mjs)
- glass-neutral + шапка/панели/FAB/overflow получают url(#filter) blur в Chromium
- Safari/Firefox: детект через @supports not (hanging-punctuation:first), остаётся чистый blur
- токены --v12-glass-neutral-filter-lg / -elevated-lg для обеих тем
2026-09-24 15:14:55 +02:00
root e3acfa072c style(hero): центрирование, уменьшение шрифта, стек кнопок на 360px, приглушённый блик 2026-09-23 15:06:21 +02:00
root 2b7361664e feat(hero): 3D-наклон фото на главной по курсору (parallax + блик) 2026-09-23 13:34:17 +02:00
root b6161c92cd feat(seo): robots.txt, ai.txt, sitemap.xml, canonical/hreflang, OG/Twitter, JSON-LD, og-image, noindex служебных 2026-09-22 11:09:26 +02:00
root 939ce8fcc9 fix(i18n): add 'home' body class to English homepage so section__title margin applies 2026-09-19 17:58:25 +02:00
root 9d78567386 style(skills): set skill-task-card__task font-size to fixed 1rem (drop clamp) 2026-09-19 17:52:08 +02:00
root 1bb50b46fb style(skills): disable uppercase on accordion titles; compact vertical rhythm (category/grid spacing) 2026-09-19 17:47:01 +02:00
root 4dd9c97481 style(skills): match accordion category heading size/weight/tracking to mp-card__title 2026-09-19 17:34:03 +02:00
root 562348cf1e style: apply soft --card-border to images, exp-card, secondary buttons, skill-task/solution cards, soften chat bubbles 2026-09-19 17:04:36 +02:00
root be4cf20a0f style(cards): soften --card-border (alpha 0.2 -> 0.15) 2026-09-19 16:55:28 +02:00
root 7aab957290 style(cards): make --card-border a single base token (muted, inherited by all themes) 2026-09-19 16:47:13 +02:00
root 7bb7fca7ae style(cards): softer visible card border (inline color-mix, text-muted; accent/brand on hover) 2026-09-19 16:22:40 +02:00
root cb24acd3dd style(cards): use softer --card-border instead of --line for card borders 2026-09-19 16:07:25 +02:00
root e325cd9173 style(home): increase section__title bottom margin (0.5rem -> 1.5rem) 2026-09-19 15:59:29 +02:00
root c8ffee1136 style(marquee): tag font-size inherits default text size (not fixed) 2026-09-19 15:49:38 +02:00
root ca7c4c2288 feat(marquee): readable 2s peak hold + cross-fade, keep <=3 bright 2026-09-19 15:46:07 +02:00
root 05b82b08a0 feat(marquee): cross-fade flicker — next tag lights before previous fades out 2026-09-19 15:39:57 +02:00
root e8c27898a7 feat(marquee): dim baseline 20%, scatter flicker order (no adjacent tags lit) 2026-09-19 15:32:30 +02:00
root 66ab7d98be feat(marquee): dim baseline 10%, flicker highlights <=3 tags to full 2026-09-19 15:27:40 +02:00
root a8bc87dff5 feat(marquee): shift flicker so tags fade out by 10% (visible at cycle start) 2026-09-19 15:23:51 +02:00
root 10f94cc306 feat(marquee): flicker animation — fade in/show/fade-out per tag, max 3 visible (scoped to grid) 2026-09-19 15:13:45 +02:00
root d652a63dda style(marquee): center the tag grid 2026-09-19 15:08:15 +02:00
root 971fe6013f style(marquee): more breathing room (gap 1.5rem x 3rem) 2026-09-19 15:03:53 +02:00
root 82e0e0d67a style(marquee): increase spacing between tags (more breathing room) 2026-09-19 15:01:13 +02:00
root b596a0e80a style(marquee): render grid tags as plain text (remove pill chrome) 2026-09-19 14:59:27 +02:00
root 32d5d82db9 style(marquee): apply .section padding + wrap content in .section__inner 2026-09-19 14:00:15 +02:00
root 45d5b18916 style(marquee): chips fill container width and wrap (flex-wrap, auto-width cells) 2026-09-19 13:54:23 +02:00
root 7e75d728a2 content(home): add Product-Market Fit & ARPU/ARPPU tags to marquee grid 2026-09-19 13:49:32 +02:00
root f9c8029849 content(home): marquee tags — drop a11y, CJM→CJM/UJM, add JTBD & IA (ru/en) 2026-09-19 13:48:04 +02:00
root 06f65953aa content(home): add 6 product-designer skill tags to marquee grid (ru/en) 2026-09-19 13:44:44 +02:00
root 9e62a1ea53 feat(marquee): alternative static flex-grid of chips (replaces scrolling marquee) 2026-09-19 13:11:13 +02:00
root e60d791934 style(home): section titles use hero__eyebrow label style (homepage only) 2026-09-19 12:59:44 +02:00
root 7d2a213491 Резюме: раздел «О себе» перенесён перед «Опыт работы» 2026-09-18 15:22:45 +02:00
root 5ce455d81b Резюме: формат работы «удалённо, гибрид» 2026-09-18 15:17:21 +02:00
root 2d67a5be5a feat(contact-card): hover glow uses card brand color (not global accent) 2026-09-18 14:04:50 +02:00
root 32fecdb3e4 feat(contact-card): hover lift + soft accent glow (match mp-card) 2026-09-18 14:02:10 +02:00
root 15c7bbc741 fix(mp-card): prevent grid from clipping hover glow; soften accent shadow 2026-09-18 13:27:01 +02:00
root 6b4e20c502 feat(mp-card): hover lift + soft accent glow on linked cards 2026-09-18 13:12:46 +02:00
root f947305a09 content(activity): drop trailing periods from card descriptions 2026-09-18 13:02:25 +02:00
root 8caeb3147c content(activity): apply user-edited compact-card descriptions (ru/en) 2026-09-18 12:57:08 +02:00
root b9d98a72df content(activity): rephrase compact-card descriptions to avoid repeating tag words 2026-09-18 12:30:55 +02:00
root b935640268 style(mp-card): move tags to bottom in activity compact cards 2026-09-18 12:24:45 +02:00
root 5bb8dc8ad2 feat(mp-card): deterministic pastel tag colors (distinct per content), padding 0.35rem 0.7rem, dark-theme aware 2026-09-18 11:14:46 +02:00
root 6d66e4cf12 feat(mp-card): color portfolio card tags with deterministic pastel palette (constant per content) 2026-09-17 22:37:22 +02:00
root 5f07f925f2 style(mp-card): restyle mp-card__tag to filled chips; sync design-system portfolio-card specimen to real homepage card 2026-09-17 21:52:50 +02:00
root 9f1b6c6f15 style(mp-card): match portfolio card text sizing to experience cards 2026-09-17 21:42:27 +02:00
root 588e3a2b9d fix(mp-card): apply stack-bottom variant to portfolio section, revert activity section 2026-09-17 21:30:13 +02:00
root f1f1ead1f8 feat(mp-card): add mp-card--stack-bottom variant (text top, image bottom) and apply to mini-projects-grid 2026-09-17 20:42:13 +02:00
root 0c172aecfe fix(anchors): rename #projects → #portfolio and #mini-projects → #activity (links + targets + css) 2026-09-17 18:38:13 +02:00
root 464816fba7 fix(header): remove #main margin-top jump when header--scrolled appears 2026-09-17 18:29:21 +02:00
root 3e5ec5b564 style(footer): lang-switch word accent + normal weight; unify footer font-size 2026-09-17 18:20:12 +02:00
root e29a0619b2 feat(lang-switch): footer shows single word — 'English' on RU pages, 'Русский' on EN; keep path-aware href 2026-09-17 18:13:30 +02:00
root 1ce19f9e69 chore: cache-bust prod build (assets 404 fix) 2026-07-24 20:55:09 +02:00
408 changed files with 29355 additions and 9747 deletions

No files matched your search

@@ -0,0 +1,19 @@
# Plan: update AI Assistant case
## Goal
Update the AI Assistant case using the facts collected in the interview. Keep the Russian and English case pages aligned, and distinguish the working plugin from the planned direction shown in the final video.
## Approach
Replace placeholder and inaccurate narrative copy with the real workflow: scripts are authored with an LLM in Cursor; a local server and WebSocket let the Figma plugin retrieve and run them; saved scripts can be launched from the plugin independently of Cursor. Describe context copying, execution logs, test-first workflow, observed use cases, and qualitative outcome without inventing metrics. Keep the final video and add a separate plans section that states the interface is currently a concept and its features do not yet work.
## Steps
1. Update `src/pages/cases/ai-assistant-figma.html`: introduction, context, task, solution, result, add a plans section, and give the final video a caption that clearly marks its UI as not yet functional.
2. Update `src/pages/cases/ai-assistant-figma.en.html` to match the Russian case.
3. Run `npm run build:pages`; inspect built Russian and English pages and verify the first-version narrative, plans distinction, and replacement of placeholders.
4. Review the diff and commit only the two case source files. Leave the pre-existing modification to `texts/content-edits/pending.json` untouched. Do not push.
## Acceptance checks
- Clearly distinguish the working first version from the planned interface shown in the final video; state that planned agent features are not implemented.
- Outcome uses the user's stated estimate (1–30 minutes per task reduced to one click) as an approximate personal observation, not as a measured study result.
- No unprovided facts, timelines, or user research are introduced.
- Russian and English pages build successfully and remain aligned.
@@ -0,0 +1,19 @@
# Plan: add callouts and distribute AI Assistant case media
## Goal
Use the site's existing long-form case components and spread relevant media through the AI Assistant story rather than leaving screenshots together.
## Approach
Use the documented `.case-callout` markup for the core product value and the observed time saving. Reposition existing screenshots and demos beside the narrative they illustrate: script handoff, script catalog/reuse, debugging/logs, and generated Figma work. Keep the final UI-only video in the Plans section. Do not invent feature behavior beyond the user's description and what is visible in the assets.
## Steps
1. Edit `src/pages/cases/ai-assistant-figma.html` to add callout block(s) and rearrange figure/video elements around their related text.
2. Apply the same structure and matching captions to `src/pages/cases/ai-assistant-figma.en.html`.
3. Run `npm run build:pages`, inspect the built Russian and English pages, and verify all media references remain intact and distributed.
4. Review and commit only the affected case sources, generated pages, and sitemap. Leave `texts/content-edits/pending.json` untouched; do not push.
## Acceptance checks
- At least one `.case-callout` uses the documented markup and communicates a real case insight.
- Static screenshots are placed near the text they illustrate, not in one consecutive cluster.
- Russian and English pages remain structurally aligned and build successfully.
- Existing video assets, including the planned-interface video, remain present with their status clear.
@@ -0,0 +1,23 @@
# Plan: merge dev into main and redeploy
## Goal
Merge the current local `dev` work into `main` and verify the production redeploy, without including pre-existing working-tree changes.
## Approach
The working copy is on local `dev` at `c8a9c82`; it is 47 commits ahead of `origin/dev`. `origin/dev` is already an ancestor of `main`, so merging that remote-tracking ref would add nothing. Local `dev` has 14 commits not in `main`; local `main` has one unique domain-verification commit, so this requires a merge commit. Use a temporary worktree for `main` so the modified `texts/content-edits/pending.json` and untracked `.hermes/` remain untouched and the active branch stays `dev`.
## Steps
1. Push the confirmed local `dev` (`c8a9c82`) to `origin/dev` as a fast-forward; confirm the remote ref.
2. Create a temporary worktree at local `main`, fast-forward it to `origin/main`, then merge local `dev`; resolve only merge conflicts, if any.
3. Verify the merge commit and changed-file scope; push the merge commit to `origin/main`.
4. Verify the configured production deployment starts after the `main` push and check the deployed case/HTTP response. If no deployment starts, use only a confirmed, supported manual trigger; do not run the obsolete deploy script.
5. Remove the temporary worktree; leave the current `dev` branch and pre-existing dirty files untouched.
## Verification
- Confirm `origin/main` points to the merge commit after push.
- Confirm the production endpoint responds with the merged revision's content after deployment.
- Confirm `pending.json` and `.hermes/` are not part of the merge commit.
## Risks / open questions
- The local `dev` branch contains 47 commits not pushed to `origin/dev` (14 commits differ from `main`); merging local `dev` publishes those changes to production.
- `AGENTS.md` says pushing `main` triggers deployment, while the site-portfolio deployment note says the old deploy path is outdated and a proper workflow is still pending. Confirm the configured production trigger before relying on either path; do not use the obsolete deploy script.
@@ -0,0 +1,15 @@
# План: пункт «Активность» в главном меню
## Цель
Поставить пункт «Активность» сразу после «Портфолио» в навигации главной страницы.
## Изменения и проверка
1. Переставить ссылку `#activity` сразу после `#portfolio` в общем `src/partials/header-home.html`; общий partial используется русской и английской версиями.
2. Выполнить `npm run build:pages` и проверить порядок ссылок в собранных RU/EN страницах.
3. Закоммитить только изменённый partial и относящиеся к сборке файлы; не включать изменённый `texts/content-edits/pending.json` и локальный `.hermes/`.
4. Запушить `dev`, затем слить его в `main` отдельным merge-коммитом и запушить `main`, сохранив активной ветку `dev`.
5. Запустить ручной редеплой Coolify с уже разрешённым токеном из `.env`; проверить статус deployment и порядок пунктов на production.
## Проверки безопасности
- До коммитов и пушей повторно сверить ветки и рабочее дерево.
- Не использовать force push; сохранять все изменения, не относящиеся к навигации.
@@ -0,0 +1,34 @@
# План: оформить кейс site-portfolio
## Цель
Сверстать страницу как личный рассказ об опыте Николая с LLM и агентами при создании сайта-портфолио, а не как классический кейс продуктового дизайнера.
## Подход
Сохранить общий каркас и токены сайта, но заменить заглушки связным нарративом: от отсутствия актуального портфолио и ограничений конструкторов к работе с кодом, Cursor и Hermes Agent. Использовать только факты из интервью; не добавлять неподтверждённые метрики, результаты или иллюстрации. Синхронно обновить английскую версию.
## Предлагаемая структура
1. Первый экран: «Как я создавал сайт-портфолио с LLM» и краткий личный подзаголовок.
2. Контекст: нужен был актуальный сайт с работами и резюме; опыт HTML/CSS и конструкторов.
3. Почему конструктора не хватило: ограничения платформ и медленные итерации.
4. Переход к LLM и Cursor: эксперименты с генерацией главной, затем IDE и появление агентного режима.
5. Рабочий процесс: постановка задач, собственная структура страниц, проверка результата в браузере, референсы, графические редакторы и дизайн-система.
6. Смена роли и инструментов: больше креативных решений, ветки для экспериментов, переход к Hermes Agent и причины.
7. Результат: сайт работает; следующие этапы — контент и продвижение.
## Шаги
1. Переписать русскую и английскую страницы кейса, включая заголовок, вводный текст и заполнение существующих разделов.
2. Использовать существующую ширину статьи и компоненты; при необходимости добавить только локальную разметку для компактного brief-блока, без глобальных CSS-изменений.
3. Запустить `npm run build:pages`.
4. Проверить оба URL, тексты локалей, наличие устаревших заглушек и корректность собранного HTML.
5. После проверки сделать локальный commit согласно AGENTS.md.
## Файлы
- `src/pages/cases/site-portfolio.html`
- `src/pages/cases/site-portfolio.en.html`
- соответствующие сгенерированные страницы в `public/cases/site-portfolio/` и `public/en/cases/site-portfolio/`
## Проверка
`npm run build:pages`; запросить RU/EN страницы через dev-сервер и проверить статус, содержимое и отсутствие заглушек в уже заполненных разделах.
## Ограничения
Не придумывать точные даты, цифры экономии, метрики, пользовательские результаты, технические сложности или детали дизайна. Не добавлять изображения без проверки и предоставленных/найденных подходящих ассетов. Раздел о сложностях не заполнять до ответа пользователя.
@@ -0,0 +1,27 @@
# План: публикация site-portfolio в main
## Цель
Опубликовать последние изменения кейса из `dev` в `origin/main` и проверить автоматический production-деплой сайта.
## Текущее состояние
- Локальный `dev`: `adf0f4b` (`origin/dev` отстаёт на 4 коммита).
- `origin/main`: `fd8e524`; Gitea refs подтверждают этот SHA.
- Ветки разошлись: `main` содержит отдельный коммит с domain verification и merge-коммиты; `dev` содержит 4 коммита кейса. Fast-forward напрямую невозможен.
- Рабочее дерево содержит чужие/несвязанные изменения: `texts/content-edits/pending.json` и незакоммиченный `.hermes/`. Их не включать.
- Для репозитория правило AGENTS.md: production-деплой запускается пушем в `main`. Coolify-приложение `static-site` связано с `admin/site-portfolio` и доменом `mamadaevv.site`.
## Подход
Создать временный изолированный worktree от актуального `origin/main`, влить туда текущий `dev` обычным merge-коммитом и отправить merge-коммит в `main` без force-push. Так сохраняется main-only domain verification и не затрагиваются незакоммиченные файлы в основной рабочей копии. После пуша проверить, что production отдал новую страницу кейса.
## Шаги
1. После подтверждения обновить remote refs и убедиться, что `origin/main` не изменился; при изменении остановиться и пересмотреть план.
2. Создать временный worktree на основе `origin/main`, влить `dev`; при конфликте остановиться и показать его, не разрешать молча.
3. Убедиться, что merge содержит изменения кейса, затем отправить `HEAD` в `origin/main` без force-push.
4. Проверить SHA `origin/main` и production URL `https://mamadaevv.site/cases/site-portfolio/` на актуальный заголовок/содержимое; проверить завершение deployment по доступным сигналам.
5. Удалить временный worktree после успешной публикации.
## Риски и границы
- Ветки разошлись, поэтому нужен merge-коммит; fast-forward или force-push не использовать.
- Если main ушёл вперёд или merge конфликтует, остановиться до записи на remote.
- Не трогать `pending.json`, незакоммиченный `.hermes/` и текущую ветку/рабочую копию.
- Автодеплой ожидается после push в `main`; если production не обновится, не утверждать успех и отдельно диагностировать webhook/деплой.
@@ -0,0 +1,69 @@
# Card Columns case edits implementation plan
> **For Hermes:** Execute only after the user confirms this plan. Keep the work on local branch `dev`; do not push or deploy.
**Goal:** Apply the nine queued author edits to the Card Columns case in Russian and English, preserving the queue and existing media.
**Architecture:** Treat `texts/content-edits/pending.json` as the user’s instruction source, but edit only the RU/EN case sources and the text snapshot. Keep each factual statement grounded in the supplied notes, the shipped 0.9.0 plugin, and live project listings; use a callout for the multi-column note behavior and another for the presentation benefit.
**Tech Stack:** Static HTML pages, Node `build:pages`, Gitea-hosted source repository.
---
### Task 1: Reframe the problem and Bases context
**Objective:** Correct the description of the built-in Cards grid and explain Bases from the user's visible UI rather than from `.base` internals.
**Files:**
- Modify: `src/pages/cases/card-columns-obsidian.html`
- Modify: `src/pages/cases/card-columns-obsidian.en.html`
**Verification:** Confirm both locales say the built-in Cards view shows a grid, and describe Columns as another card view with column grouping.
### Task 2: Add the core multi-column use case and interface rationale
**Objective:** Surface the original pain in a callout with the supplied links and movies examples; add the Obsidian design-system / code-first UI note.
**Files:**
- Modify: `src/pages/cases/card-columns-obsidian.html`
- Modify: `src/pages/cases/card-columns-obsidian.en.html`
**Verification:** Check the shipped grouping code handles multiple frontmatter values and make the callout factual, not a claim about every other plugin.
### Task 3: Apply settings, release workflow, and outcome edits
**Objective:** Rename the settings subsection to reflect logical grouping, remove the obsolete separate-board sync sentence, replace the timing disclaimer with the presentation benefit, and describe the Hermes → laptop test → Gitea/GitHub release workflow and manual Community publication step.
**Files:**
- Modify: `src/pages/cases/card-columns-obsidian.html`
- Modify: `src/pages/cases/card-columns-obsidian.en.html`
**Verification:** Preserve the distinction between automated repo release steps and the user's manual Obsidian Community publication action.
### Task 4: Update public evidence in both locales
**Objective:** Replace the old result paragraph with the end-of-September download count, GitHub stars, and issue history using accurate wording.
**Files:**
- Modify: `src/pages/cases/card-columns-obsidian.html`
- Modify: `src/pages/cases/card-columns-obsidian.en.html`
**Verified evidence:** Obsidian Community lists version 0.9.0 and 846 downloads; GitHub reports 4 stars and 2 issues total (one closed bug report, one open feature request). Do not describe both issues as enhancement requests.
### Task 5: Synchronize snapshot, build, verify, and commit locally
**Objective:** Keep the text snapshot and localized pages aligned, build the generated pages, verify rendering and links, then commit only this task's files on local `dev`.
**Files:**
- Modify: `texts/cases/card-columns-obsidian.md`
- Generated: `public/cases/card-columns-obsidian/index.html`
- Generated: `public/en/cases/card-columns-obsidian/index.html`
- Possibly generated: `public/sitemap.xml`
**Verification:** `npm run build:pages`; `git diff --check`; HTML well-formedness; browser check of RU/EN copy, callouts, Obsidian Community link, and media paths. Leave `texts/content-edits/pending.json` and unrelated `.hermes/` content untouched. Do not push or deploy.
## Risks
- Current GitHub issue state is mixed: one issue is closed as a bug report and one remains open as a feature request. Phrase the total and states accurately.
- The local `dev` branch currently includes the production merge commit; this task must add a local-only commit and must not move or push remote refs.
- The content-edit queue remains intact by project convention; the user removes completed items after reviewing the result.
@@ -0,0 +1,37 @@
# Горизонтальный лэйаут карточек портфолио — план реализации
> **Для Hermes:** реализовывать после подтверждения Николая; не создавать новый компонент или новые классы-модификаторы.
**Цель:** расширить существующие `.mp-card--stack` и `.mp-card--link` горизонтальным лэйаутом — текст слева, изображение справа.
**Подход:** использовать текущую структуру `.mp-card` и её дочерние `.mp-card__body` / `.mp-card__media`, сохраняя `.mp-card--link` для ссылочного поведения. Изменить существующие стили стека так, чтобы карточка получила горизонтальную компоновку; сохранить прежний вариант для текущих потребителей и сделать новый вид адаптивным. Показать существующий класс в каталоге дизайн-системы.
**Стек:** HTML, CSS, существующая сборка страниц.
---
### Задача 1: Расширить существующий стек горизонтальным лэйаутом
**Цель:** расположить текстовый блок слева, изображение справа и адаптировать раскладку к узким экранам, не создавая новый компонент или модификатор.
**Файлы:**
- Изменить: `public/css/main.css` (стили `.mp-card` и его существующих layout-модификаторов около строк 3433–3498; проверить правила `.mp-card__media` и связанные responsive-правила перед изменением).
**Проверка:** проверить области применения `.mp-card--stack` и `.mp-card--link`; исключить регрессии других карточек и выполнить `npm run build:pages`.
### Задача 2: Обновить демонстрацию существующей карточки
**Цель:** показать горизонтальный лэйаут существующего `.mp-card--stack` + `.mp-card--link` в каталоге дизайн-системы и обновить подпись.
**Файлы:**
- Изменить: `src/pages/cases/design-system.html` (демо карточки портфолио около строк 756–802).
**Проверка:** собрать страницы и проверить пример на широком и узком экранах.
### Задача 3: Проверить результат и зафиксировать
**Цель:** проверить новый лэйаут и прежних потребителей карточек.
**Проверка:** `npm run build:pages`, открыть собранный каталог через dev-сервер, проверить responsive-вид; выполнить `git diff --check` и осмотреть diff. После успешной проверки создать локальный git commit согласно `AGENTS.md`; не пушить.
**Открытый вопрос:** `.mp-card--stack` задействован также вне каталога портфолио; при реализации нужно сохранить существующий стековый вариант там, где он уже используется, добавив горизонтальный лэйаут в рамках уже существующих классов, без нового имени класса.
+8 -2
View File
@@ -2,13 +2,19 @@
## Сборка и деплой
Деплой автоматический: пуш в git → автодеплой на хостинг.
Деплой автоматический, но **только по пушу в ветку `main`** → автодеплой на хостинг.
Пуш в любую другую ветку (включая `dev`) деплоя **не вызывает** — `dev` это обычная рабочая ветка, в неё можно пушить свободно.
Правило коммитов: **после каждой правки делай локальный git commit** (без пуша, если не просили). Пуш — только по явному запросу пользователя, и для деплоя он должен идти в `main`.
По запросу пользователя «собрать», «билд»:
1. При правках `src/pages/**` или `src/partials/**` — `npm run build:pages`.
2. При смене шрифтов/Plyr или `package.json` — `npm run build` (включает `vendor:copy`).
3. Для деплоя — сделать git commit + push.
3. Для деплоя — пуш в `main` (предварительно сделав commit).
> Если пользователь просит «влить в мастер» — см. раздел «Влить в master» ниже. Это не то же самое, что пуш в `dev`.
## Git
+36 -1
View File
@@ -47,7 +47,7 @@
| `data-font` | Стек | Назначение |
|---|---|---|
| `merriweather` | Merriweather + Alice | По умолчанию. Alice — проза, Merriweather — заголовки |
| `geologica` | Geologica + PT Sans | Гротеск + нейтральная проза |
| `medeo` | Medeo Sans | Гротеск, интерфейс и проза |
| `mono` | Source Code Pro + PT Mono | Моноширинный |
### Шкала
@@ -147,6 +147,34 @@
| `.hero__tags` | Контейнер тегов в Hero |
| `.hero__tag` | Тег (пилл с рамкой). Используйте для ключевых слов |
| `.portfolio-card__tag` | Тег внутри карточки портфолио |
| `.mp-card__tags` | Контейнер тегов в mp-card |
| `.mp-card__tag` | Тег внутри mp-card |
### Фильтр-чипы
Переключаемая группа тегов над списком карточек. Нажатый чип оставляет только элементы с
совпадающим тегом, остальные скрываются.
| Класс / атрибут | Назначение |
|---|---|
| `.filter-chips` | Контейнер чипов (`<ul>`). Атрибут `data-filter-chips` включает поведение |
| `.filter-chip` | Кнопка-чип. Атрибут `data-filter-value` = тег, который она включает |
| `.filter-chip__count` | Счётчик совпадений. Проставляется скриптом автоматически |
| `data-filter-scope` | Селектор контейнера с фильтруемыми элементами. По умолчанию `document` |
| `data-filter-item` | Помечает фильтруемый элемент |
| `data-filter-tag` | Теги элемента через пробел. Несколько тегов работают как OR |
Правила:
- Состояние держится в `aria-pressed="true|false"`, а не в классе. Оформление выбранного
чипа — `[aria-pressed="true"]`, поэтому оно не зависит от `:focus` и не залипает после клика.
- Значение `all` (или пустое) показывает все элементы. Такой чип обычно первый.
- Скрытие через атрибут `hidden` + `[data-filter-item][hidden] { display: none }` — сетка
не оставляет дыр. Плавное затухание потребовало бы анимации высоты, а для фильтра
мгновенный отклик важнее.
- JS: `initFilterChips()` в `public/js/modules/ui/filter-chips.js`, подключён в `main.js`.
Счётчики и стартовое состояние берутся из разметки, модуль сам их заполняет.
- Помечайте элементы `hidden`, а не удаляйте — иначе фильтр ломает раскладку при возврате.
### Карточки
@@ -158,6 +186,13 @@
| `.skill-task-card` | Карточка навыка (задача + решения) |
| `.contact-card` | Контактная карточка (ссылка или действие) |
### Сетка проектов
| Класс | Назначение |
|---|---|
| `.project-grid` | Сетка карточек проекта: 1 колонка на мобильном, 2 от `680px`, 3 от `1120px`; адаптивные интервалы между карточками |
| `.project-grid > li` | Элемент списка без собственного оформления; внутри используется компонент карточки, например `.mp-card` |
### Блоки лонгрида
| Класс | Назначение |
+14
View File
@@ -0,0 +1,14 @@
# Build the static site and serve it with nginx
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build:pages
FROM nginx:alpine
# Fix mjs MIME type — without this plyr.min.mjs fails to load as ES module
RUN sed -i 's|application/javascript *js;|application/javascript js mjs;|' /etc/nginx/mime.types
COPY --from=build /app/public /usr/share/nginx/html
EXPOSE 80
# prod rebuild 1784919309
+1
View File
@@ -0,0 +1 @@
Мой сайт портфолио
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+18
View File
@@ -0,0 +1,18 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({ args: ["--no-sandbox","--disable-dev-shm-usage"] });
const p = await b.newPage();
await p.setViewport({ width: 768, height: 900, deviceScaleFactor: 1 });
await p.goto("http://localhost:4175/", { waitUntil: "networkidle0" });
await p.evaluate(() => window.scrollTo(0, 400));
await new Promise(r=>setTimeout(r,400));
await p.evaluate(() => document.querySelector("[data-nav-more-btn]")?.click());
await new Promise(r=>setTimeout(r,300));
const m = await p.evaluate(() => {
const menu = document.querySelector("#nav-more-menu-floating");
if (!menu || menu.hidden) return { hidden: true };
const mr = menu.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
return { rightFromEdge: Math.round(vw - mr.right), edgePad: getComputedStyle(document.documentElement).getPropertyValue("--edge-pad").trim() };
});
console.log("MENU@768:", JSON.stringify(m));
await b.close();
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1500});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView());
await new Promise(r=>setTimeout(r,1500));
const r = await p.evaluate(()=>{
const cells=[...document.querySelectorAll('[data-shader-cell]')];
const cellInfo=cells.map(c=>{const m=c.querySelector('.card-shader')?.paperShaderMount; return {key:c.dataset.shaderCell, mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null};});
const demo=document.querySelector('[data-shader-demo]');
const demoMounted=!!demo.querySelector('.card-shader')?.paperShaderMount;
// переключим на spiral и проверим label
const btn=document.querySelector('[data-shader-switch="spiral"]');
let labelAfter=null;
if(btn){ btn.click(); labelAfter=document.querySelector('[data-shader-label]')?.textContent; }
return { cellCount: cells.length, cellInfo, demoMounted, labelAfter };
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,8));
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,600));
const snap = ()=>p.evaluate(()=>{
const cv=document.querySelector('[data-shader-demo] canvas');
const gl=cv.getContext('webgl2',{preserveDrawingBuffer:true})||cv.getContext('webgl',{preserveDrawingBuffer:true});
const a=new Uint8Array(4*9); let k=0;
for(const [x,y] of [[20,20],[100,80],[200,150],[50,180],[300,40]]){ gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a.subarray(k,k+4)); k+=4; }
return Array.from(a).join(',');
});
const s1=await snap(); await new Promise(r=>setTimeout(r,700)); const s2=await snap();
console.log('frame1:', s1);
console.log('frame2:', s2);
console.log('MOVING:', s1!==s2);
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,600));
async function shot(){ const el=await p.$('[data-shader-demo]'); await el.screenshot({path:'/tmp/a.png'});
const {data,info}=await sharp('/tmp/a.png').raw().toBuffer({resolveWithObject:true});
return {w:info.width,h:info.height,data}; }
const s1=await shot(); await new Promise(r=>setTimeout(r,800)); const s2=await shot();
// сравним центральные пиксели
function center(d,info){ const i=((info.height>>1)*info.width+(info.width>>1))*3; return [d[i],d[i+1],d[i+2]]; }
const c1=center(s1.data,s1), c2=center(s2.data,s2);
console.log('center px t1:', c1, ' t2:', c2, ' MOVED:', JSON.stringify(c1)!==JSON.stringify(c2));
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-150), top);
await new Promise(r=>setTimeout(r,700));
async function region(){ await p.screenshot({path:'/tmp/r.png'});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const {data,info}=await sharp('/tmp/r.png').raw().toBuffer({resolveWithObject:true});
// хеш центральной полосы
const samples=[]; const y=Math.floor(rect.y+rect.h*0.4);
for(let x=Math.floor(rect.x+20); x<rect.x+rect.w-20; x+=15){ const i=(Math.floor(y)*info.width+Math.floor(x))*3; samples.push(data[i]+','+data[i+1]+','+data[i+2]); }
return samples.join('|');
}
const a=await region(); await new Promise(r=>setTimeout(r,800)); const c=await region();
console.log('moved:', a!==c);
console.log('t1 head:', a.slice(0,60)); console.log('t2 head:', c.slice(0,60));
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import crypto from 'crypto';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-150), top);
await new Promise(r=>setTimeout(r,700));
async function hash(){ await p.screenshot({path:'/tmp/rl.png'});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const {data,info}=await sharp('/tmp/rl.png').raw().toBuffer({resolveWithObject:true});
const x0=Math.floor(rect.x),y0=Math.floor(rect.y),x1=Math.floor(rect.x+rect.w),y1=Math.floor(rect.y+rect.h);
const buf=Buffer.alloc((x1-x0)*(y1-y0)*3);
let k=0; for(let y=y0;y<y1;y++) for(let x=x0;x<x1;x++){ const i=(y*info.width+x)*3; buf[k++]=data[i];buf[k++]=data[i+1];buf[k++]=data[i+2]; }
return crypto.createHash('md5').update(buf).digest('hex');
}
const a=await hash(); await new Promise(r=>setTimeout(r,3000)); const c=await hash();
console.log('hash1', a); console.log('hash2', c); console.log('ANIMATED:', a!==c);
await b.close();
+26
View File
@@ -0,0 +1,26 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:800,height:600});
await p.goto('about:blank',{waitUntil:'load'});
await new Promise(r=>setTimeout(r,300));
const r = await p.evaluate(()=>{
const cv=document.createElement('canvas'); cv.width=64; cv.height=64; document.body.appendChild(cv);
const gl=cv.getContext('webgl2');
if(!gl) return {webgl2:false};
const vs=`#version 300 es\nin vec4 a_position; void main(){ gl_Position=a_position; }`;
const fs=`#version 300 es\nprecision mediump float; out vec4 o; void main(){ o=vec4(1.0,0.3,0.0,1.0); }`;
const mk=(s)=>{const sh=gl.createShader(s.includes('main(){ gl_Position')?gl.VERTEX_SHADER:gl.FRAGMENT_SHADER);gl.shaderSource(sh,s);gl.compileShader(sh);return {sh,ok:gl.getShaderParameter(sh,gl.COMPILE_STATUS),log:gl.getShaderInfoLog(sh)};};
const v=mk(vs), f=mk(fs);
const prog=gl.createProgram(); gl.attachShader(prog,v.sh); gl.attachShader(prog,f.sh); gl.linkProgram(prog);
const linked=gl.getProgramParameter(prog,gl.LINK_STATUS);
gl.useProgram(prog);
const buf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buf); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
const loc=gl.getAttribLocation(prog,'a_position'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES,0,6);
const px=new Uint8Array(4); gl.readPixels(32,32,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { webgl2:true, vsOk:v.ok, fsOk:f.ok, fsLog:f.log, linked, center:Array.from(px) };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-150), top);
await new Promise(r=>setTimeout(r,800));
await p.screenshot({path:'/tmp/sec.png'});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const {data,info}=await sharp('/tmp/sec.png').raw().toBuffer({resolveWithObject:true});
const x0=Math.floor(rect.x), y0=Math.floor(rect.y), x1=Math.floor(rect.x+rect.w), y1=Math.floor(rect.y+rect.h);
let maxR=0,maxG=0,maxB=0, brightPx=0, n=0;
for(let y=Math.max(0,y0);y<Math.min(info.height,y1);y++) for(let x=Math.max(0,x0);x<Math.min(info.width,x1);x++){ const i=(y*info.width+x)*3; maxR=Math.max(maxR,data[i]);maxG=Math.max(maxG,data[i+1]);maxB=Math.max(maxB,data[i+2]); if(data[i]+data[i+1]+data[i+2]>250) brightPx++; n++; }
console.log('max channel values in demo card:', maxR, maxG, maxB, 'bright pixels:', brightPx, '/', n);
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1500});
await p.goto('http://10.200.201.3:4175/',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(()=>{
const pick = (sel)=>{ const el=document.querySelector(sel); if(!el) return null;
const cs=getComputedStyle(el);
return { boxShadow: cs.boxShadow.slice(0,60), shadowLen: cs.boxShadow.length, transform: cs.transform, bg: cs.backgroundImage.slice(0,40) }; };
return {
mp: pick('.mp-card'),
portfolio: pick('.portfolio-card'),
skill: pick('.skill-task-card'),
theme: document.documentElement.getAttribute('data-theme'),
};
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,6));
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{ if(m.type()==='error') errs.push(m.text()); });
p.on('pageerror',e=>errs.push('PAGEERR '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const cards = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>{
const cs = getComputedStyle(e);
const before = getComputedStyle(e,'::before');
const img = e.querySelector('img');
return { name:e.querySelector('.contact-card__name')?.textContent,
boxShadow: cs.boxShadow.slice(0,40),
beforeBg: before.backgroundImage.slice(0,60),
borderW: cs.borderTopWidth,
imgLoaded: img ? (img.complete && img.naturalWidth>0) : 'no-img' };
}));
console.log(JSON.stringify({count:cards.length, cards, errs}, null, 1));
await b.close();
+13
View File
@@ -0,0 +1,13 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{ if(m.type()==='error') errs.push(m.text()); });
p.on('pageerror',e=>errs.push('PAGEERR '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
// dark
const dark = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>({n:e.querySelector('.contact-card__name')?.textContent, bs:e.style.boxShadow?'inline':'', shadow:getComputedStyle(e).boxShadow.length})));
// light theme
await p.evaluate(()=>document.documentElement.setAttribute('data-theme','light'));
const light = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>({n:e.querySelector('.contact-card__name')?.textContent, shadow:getComputedStyle(e).boxShadow.slice(0,30)})));
console.log(JSON.stringify({darkCount:dark.length, light, errs}, null, 1));
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1400});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const r = await p.evaluate(()=>{
const shaderCards=[...document.querySelectorAll('.card-shader[data-shader]')];
const mounted=shaderCards.map(w=>!!w.paperShaderMount);
return {
hasSecShader: !!document.querySelector('#sec-shader'),
hasSecShaderGrid: !!document.querySelector('#sec-shader-grid'),
hasSecContact: !!document.querySelector('#sec-contact'),
totalShaderWraps: shaderCards.length,
allMounted: mounted.every(Boolean),
mountedCount: mounted.filter(Boolean).length,
};
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,5));
await b.close();
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
await p.setViewport({width:1280,height:1000});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e = document.querySelector(s);
const cs = getComputedStyle(e);
const bs = cs.boxShadow.split('),').map(x=>x.trim());
return {
bg: cs.backgroundColor,
boxShadow: bs,
};
}, sel);
const neu = await probe('#sec-final .neu-card');
const cc = await probe('#sec-contact .contact-card');
console.log('=== NEU-CARD (Финал) ===');
console.log(JSON.stringify(neu,null,1));
console.log('=== CONTACT-CARD ===');
console.log(JSON.stringify(cc,null,1));
await b.close();
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e = document.querySelector(s); const cs=getComputedStyle(e);
return { bg: cs.backgroundColor, bgImg: cs.backgroundImage.slice(0,55), shadow: cs.boxShadow.split('),').map(x=>x.trim().replace(/rgba\([^)]*\)/,'C')) };
}, sel);
console.log('DARK');
console.log(' neu:', JSON.stringify(await probe('#sec-final .neu-card')));
console.log(' cc :', JSON.stringify(await probe('#sec-contact .contact-card')));
await p.evaluate(()=>document.documentElement.setAttribute('data-theme','light'));
console.log('LIGHT');
console.log(' neu:', JSON.stringify(await probe('#sec-final .neu-card')));
console.log(' cc :', JSON.stringify(await probe('#sec-contact .contact-card')));
await b.close();
+11
View File
@@ -0,0 +1,11 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e = document.querySelector(s); const cs=getComputedStyle(e);
return { bgImg: cs.backgroundImage.slice(0,60) };
}, sel);
console.log('DARK neu bgImg:', (await probe('#sec-final .neu-card')).bgImg);
console.log('DARK cc bgImg:', (await probe('#sec-contact .contact-card')).bgImg);
await b.close();
+19
View File
@@ -0,0 +1,19 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e=document.querySelector(s); const cs=getComputedStyle(e);
return { bg: cs.backgroundColor, bgImg: cs.backgroundImage.slice(0,45), shadowLen: cs.boxShadow.length,
dir: cs.flexDirection, pad: cs.paddingTop };
}, sel);
const neu = await probe('#sec-final .neu-card');
const cc = await probe('#sec-contact .contact-card');
const neuShadow = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-final .neu-card')).boxShadow);
const ccShadow = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-contact .contact-card')).boxShadow);
console.log('neu:', JSON.stringify(neu));
console.log('cc :', JSON.stringify(cc));
console.log('shadows equal:', neuShadow===ccShadow);
console.log('errs:', errs);
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const logs=[];
p.on('console', m=>logs.push('['+m.type()+'] '+m.text()));
p.on('pageerror', e=>logs.push('[pageerror] '+e.message));
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(async ()=>{
const mod = await import('/vendor/paper-shaders/index.js');
const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod;
const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative';
document.body.appendChild(host);
const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString);
const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 };
const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0);
return { keys: Object.keys(m.uniformLocations), uColorsLoc: m.uniformLocations.u_colors, uColorsCountLoc: m.uniformLocations.u_colorsCount };
});
console.log('RESULT', JSON.stringify(r));
console.log('LOGS:\n'+logs.join('\n'));
await b.close();
+32
View File
@@ -0,0 +1,32 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(async ()=>{
const mod = await import('/vendor/paper-shaders/index.js');
const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod;
const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative';
document.body.appendChild(host);
const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString);
const m = new ShaderMount(host, meshGradientFragmentShader, { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 }, { preserveDrawingBuffer:true, antialias:true }, 1.0);
const gl=m.gl, pr=m.program;
// правильное чтение: getUniform ВОЗВРАЩАЕТ значение
const gf=(n)=>gl.getUniform(pr, gl.getUniformLocation(pr,n));
const colorsWritten = gf('u_colors[0]'); // Float32Array(4)
const countWritten = gf('u_colorsCount'); // number
const swirlWritten = gf('u_swirl');
// readPixels скан
m.render(performance.now()); m.render(performance.now());
const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let bright=0,maxc=0; for(let i=0;i<buf.length;i+=4){const mx=Math.max(buf[i],buf[i+1],buf[i+2]); if(mx>120)bright++; maxc=Math.max(maxc,mx);}
return {
u_colors0: colorsWritten ? Array.from(colorsWritten).map(x=>+x.toFixed(2)) : null,
u_colorsCount: countWritten,
u_swirl: swirlWritten,
brightPixels: bright, maxChannel: maxc,
};
});
console.log(JSON.stringify(r,null,1));
await b.close();
+25
View File
@@ -0,0 +1,25 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(async ()=>{
const mod = await import('/vendor/paper-shaders/index.js');
const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod;
const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative';
document.body.appendChild(host);
const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString);
const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 };
console.log('uniforms passed:', JSON.stringify(uniforms.u_colors), 'count', uniforms.u_colorsCount);
const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0);
m.render(performance.now()); m.render(performance.now());
const gl=m.gl;
const cnt=new Float32Array(1); gl.getUniform(m.program, gl.getUniformLocation(m.program,'u_colorsCount'), cnt);
const c0=new Float32Array(4); gl.getUniform(m.program, gl.getUniformLocation(m.program,'u_colors[0]'), c0);
const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let bright=0,maxc=0; for(let i=0;i<buf.length;i+=4){const mx=Math.max(buf[i],buf[i+1],buf[i+2]); if(mx>120)bright++; maxc=Math.max(maxc,mx);}
return { count:cnt[0], c0:Array.from(c0).map(x=>+x.toFixed(2)), bright, maxc };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+24
View File
@@ -0,0 +1,24 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1280,height:1200});
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const demo = await p.$('[data-shader-demo]');
await demo.evaluate(el=>el.scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,400));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,700)); }
const el = await p.$('[data-shader-demo]');
const box = await el.boundingBox();
await el.screenshot({path:'/tmp/demo.png'});
const {data,info}=await sharp('/tmp/demo.png').raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height, rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=3){ rs+=data[i];gs+=data[i+1];bs+=data[i+2]; if(i%1500===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
return { key: key||'mesh', avg:[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], distinct: cols.size };
}
const r1=await probe(); const r2=await probe('grain'); const r3=await probe('voronoi'); const r4=await probe('staticMesh');
console.log(JSON.stringify({r1,r2,r3,r4,errs},null,1));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,900));
// координаты демо-карточки
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
await p.screenshot({path:'/tmp/full.png'});
const img = sharp('/tmp/full.png');
const {data,info}=await img.raw().toBuffer({resolveWithObject:true});
// вырежем центр демо-карточки (по координатам вьюпорта)
const x0=Math.floor(rect.x+rect.w*0.2), y0=Math.floor(rect.y+rect.h*0.2);
const x1=Math.floor(rect.x+rect.w*0.8), y1=Math.floor(rect.y+rect.h*0.8);
let rs=0,gs=0,bs=0,n=0; const cols=new Set();
for(let y=y0;y<y1;y+=3){ for(let x=x0;x<x1;x+=3){ const i=(y*info.width+x)*3; rs+=data[i];gs+=data[i+1];bs+=data[i+2]; cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); n++; } }
console.log('demo card region avg:', [Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], 'distinct coarse colors:', cols.size);
await b.close();
+34
View File
@@ -0,0 +1,34 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,600));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,900)); }
// снимок canvas напрямую через toDataURL -> png
const dataUrl = await p.evaluate(()=>{
const cv=document.querySelector('[data-shader-demo] canvas');
if(!cv) return null;
try { return cv.toDataURL('image/png').slice(0,30); } catch(e){ return 'ERR '+e.message; }
});
const buf = await p.evaluate(()=>{
const cv=document.querySelector('[data-shader-demo] canvas');
return cv ? cv.toDataURL('image/png') : null;
});
let distinct=0, avg=[0,0,0];
if(buf){ const b64=buf.split(',')[1]; const {data,info}=await sharp(Buffer.from(b64,'base64')).raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=4){ rs+=data[i];gs+=data[i+1];bs+=data[i+2];
if(i%2000===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
avg=[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]; distinct=cols.size; }
return { key:key||'mesh', dataUrlHead:dataUrl, avg, distinct };
}
console.log(JSON.stringify(await probe(),null,0));
console.log(JSON.stringify(await probe('grain'),null,0));
console.log(JSON.stringify(await probe('voronoi'),null,0));
console.log(JSON.stringify(await probe('staticMesh'),null,0));
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
// прокрутим ровно к карточке через window.scrollTo по абсолютному смещению
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-200), top);
await new Promise(r=>setTimeout(r,900));
await p.screenshot({path:'/tmp/full2.png'});
const {data,info}=await sharp('/tmp/full2.png').raw().toBuffer({resolveWithObject:true});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const x0=Math.max(0,Math.floor(rect.x+20)), y0=Math.max(0,Math.floor(rect.y+20));
const x1=Math.min(info.width,Math.floor(rect.x+rect.w-20)), y1=Math.min(info.height,Math.floor(rect.y+rect.h-20));
let rs=0,gs=0,bs=0,n=0; const cols=new Set();
if(x1>x0&&y1>y0){ for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){ const i=(y*info.width+x)*3; rs+=data[i];gs+=data[i+1];bs+=data[i+2]; cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); n++; } } }
console.log('region avg', n?[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]:'empty', 'distinct', cols.size, 'rect', rect, 'imgsize', info.width+'x'+info.height);
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,1000)); }
const el = await p.$('[data-shader-demo]');
await el.screenshot({path:'/tmp/demo.png'});
const {data,info}=await sharp('/tmp/demo.png').raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=3){ rs+=data[i];gs+=data[i+1];bs+=data[i+2]; if(i%2000===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
return { key:key||'mesh', avg:[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], distinct:cols.size };
}
console.log(JSON.stringify(await probe()));
console.log(JSON.stringify(await probe('grain')));
console.log(JSON.stringify(await probe('voronoi')));
console.log(JSON.stringify(await probe('staticMesh')));
await b.close();
+26
View File
@@ -0,0 +1,26 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,700));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,900)); }
const buf = await p.evaluate(()=>{ const cv=document.querySelector('[data-shader-demo] canvas'); return cv?cv.toDataURL('image/png'):null; });
let avg=[0,0,0], distinct=0;
if(buf){ const {data,info}=await sharp(Buffer.from(buf.split(',')[1],'base64')).raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=4){ rs+=data[i];gs+=data[i+1];bs+=data[i+2]; if(i%1500===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
avg=[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]; distinct=cols.size; }
return { key:key||'mesh', avg, distinct, hasCanvas:!!buf };
}
console.log(JSON.stringify(await probe(),null,0));
console.log(JSON.stringify(await probe('grain'),null,0));
console.log(JSON.stringify(await probe('voronoi'),null,0));
console.log(JSON.stringify(await probe('staticMesh'),null,0));
console.log('errs:', errs);
await b.close();
+11
View File
@@ -0,0 +1,11 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300,deviceScaleFactor:1});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,800));
await p.screenshot({path:'/tmp/sec-shader.png'});
console.log('saved');
await b.close();
+28
View File
@@ -0,0 +1,28 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1280,height:1200});
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1000));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,500));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,800)); }
return await p.evaluate(()=>{
const el=document.querySelector('[data-shader-demo]');
const cv=el.querySelector('canvas');
if(!cv) return {canvas:false};
const gl=cv.getContext('webgl2')||cv.getContext('webgl');
const px=new Uint8Array(4);
// читаем угол (0,0) — там обычно цвет градиента
gl.readPixels(2,2,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { canvas:true, w:cv.width, h:cv.height, corner:Array.from(px), key: el.dataset.shaderKey };
});
}
console.log('mesh:', JSON.stringify(await probe()));
console.log('grain:', JSON.stringify(await probe('grain')));
console.log('voronoi:', JSON.stringify(await probe('voronoi')));
console.log('staticMesh:', JSON.stringify(await probe('staticMesh')));
console.log('errs:', errs);
await b.close();
+30
View File
@@ -0,0 +1,30 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{ if(m.type()==='error'||m.type()==='warning') errs.push(m.type()+': '+m.text()); });
p.on('pageerror',e=>errs.push('PAGEERR: '+e.message));
p.on('requestfailed',r=>errs.push('REQFAIL: '+r.url()+' '+r.failure()?.errorText));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const diag = await p.evaluate(()=>{
const sec=document.querySelector('#sec-shader');
const demo=document.querySelector('[data-shader-demo]');
const canvas=document.querySelector('#sec-shader canvas');
const sw=document.querySelectorAll('[data-shader-switch]').length;
return {
secExists: !!sec,
secH: sec?Math.round(sec.getBoundingClientRect().height):0,
demoExists: !!demo,
demoH: demo?Math.round(demo.getBoundingClientRect().height):0,
canvasCount: document.querySelectorAll('#sec-shader canvas').length,
swCount: sw,
swVisible: sw?getComputedStyle(document.querySelector('[data-shader-switch]')).display:'n/a',
};
});
// проверим доступен ли модуль
const modOk = await p.evaluate(async()=>{
try { const m = await import('/vendor/paper-shaders/index.js'); return 'OK keys:'+Object.keys(m).length; }
catch(e){ return 'IMPORT ERR: '+e.message; }
});
console.log(JSON.stringify({diag, modOk, errs: errs.slice(0,10)}, null, 1));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
// два render (drawArrays инициализируется лениво на 1-м)
m.render(performance.now()); m.render(performance.now());
const W=m.canvasElement.width, H=m.canvasElement.height;
const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let bright=0, maxc=0;
for(let i=0;i<buf.length;i+=4){ const r=buf[i],g=buf[i+1],bl=buf[i+2]; const mx=Math.max(r,g,bl); if(mx>120) bright++; maxc=Math.max(maxc,mx); }
const cntLoc=gl.getUniformLocation(m.program,'u_colorsCount'); const cv=new Float32Array(1); gl.getUniform(m.program,cntLoc,cv);
const c0=new Float32Array(4); gl.getUniform(m.program,gl.getUniformLocation(m.program,'u_colors[0]'),c0);
return { brightPixels: bright, maxChannel: maxc, uColorsCountFloat: cv[0], uColor0: Array.from(c0).map(x=>+x.toFixed(2)) };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+3 -3
View File
@@ -2,7 +2,7 @@
## Генерация PDF
Скрипт `scripts/generate-resume-pdf.mjs` открывает `resume/` через Puppeteer, применяет шрифт geologica (PT Sans), убирает подложку/обводку блока `.resume-doc` и сохраняет PDF.
Скрипт `scripts/generate-resume-pdf.mjs` открывает `resume/` через Puppeteer, применяет шрифт medeo (Medeo Sans), убирает подложку/обводку блока `.resume-doc` и сохраняет PDF.
**Важно**: скрипт требует запущенный dev-сервер (`npm run serve` → `localhost:4175`).
@@ -15,12 +15,12 @@ npm run generate:resume-pdf
или вручную:
```bash
node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica
node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ medeo
```
Параметры:
- `[url]` — URL страницы (по умолчанию `http://localhost:4175/resume/`)
- `[font]` — ID шрифта: `geologica`, `merriweather`, `mono` (по умолчанию `geologica`)
- `[font]` — ID шрифта: `medeo`, `merriweather`, `mono` (по умолчанию `medeo`)
### Выходной файл
+51 -51
View File
@@ -1,51 +1,51 @@
# Локальные vendor-ассеты (шрифты и Plyr)
Сайт не использует Google Fonts и jsDelivr в рантайме. Файлы лежат в `public/` и попадают на хостинг через git push.
## Что где
| Путь | Содержимое |
|------|------------|
| `public/assets/fonts/*.css` | Три пресета темы: `merriweather`, `geologica`, `mono` |
| `public/assets/fonts/files/*.woff2` | Файлы шрифтов (из `@fontsource/*`) |
| `public/vendor/plyr/` | `plyr.css`, `plyr.min.mjs` (из `plyr@3.8.4`) |
Подключение:
- Шрифты — inline `theme-init-script` и [`theme-fonts.js`](../public/js/modules/theme/theme-fonts.js).
- CSS Plyr — partial [`src/partials/head-plyr.html`](../src/partials/head-plyr.html) только на страницах с `video[data-case-video-player]`.
- JS Plyr — [`video-player.js`](../public/js/modules/cases/video-player.js).
## Обновление
```bash
npm install
npm run vendor:copy
```
Скрипт: [`scripts/copy-vendor.mjs`](../scripts/copy-vendor.mjs). Пересобирает `public/assets/fonts/` и `public/vendor/plyr/` из `node_modules`.
Полная сборка (vendor + HTML):
```bash
npm run build
```
Только HTML из `src/`:
```bash
npm run build:pages
```
## Добавить или изменить шрифт
1. Установить пакет `@fontsource/...` или `@fontsource-variable/...`.
2. Добавить источник в `FONT_BUNDLES` в `copy-vendor.mjs`.
3. `npm run vendor:copy`.
4. Проверить имена `font-family` в [`public/css/main.css`](../public/css/main.css) (`html[data-font=...]`).
## Обновить Plyr
1. Обновить версию в `package.json` (`dependencies.plyr`).
2. `npm install` → `npm run vendor:copy`.
3. При смене API — правки в `video-player.js`.
# Локальные vendor-ассеты (шрифты и Plyr)
Сайт не использует Google Fonts и jsDelivr в рантайме. Файлы лежат в `public/` и попадают на хостинг через git push.
## Что где
| Путь | Содержимое |
|------|------------|
| `public/assets/fonts/*.css` | Три пресета темы: `merriweather`, `medeo`, `mono` |
| `public/assets/fonts/files/*.woff2` | Файлы шрифтов (из `@fontsource/*`, `medeo-font`) |
| `public/vendor/plyr/` | `plyr.css`, `plyr.min.mjs` (из `plyr@3.8.4`) |
Подключение:
- Шрифты — inline `theme-init-script` и [`theme-fonts.js`](../public/js/modules/theme/theme-fonts.js).
- CSS Plyr — partial [`src/partials/head-plyr.html`](../src/partials/head-plyr.html) только на страницах с `video[data-case-video-player]`.
- JS Plyr — [`video-player.js`](../public/js/modules/cases/video-player.js).
## Обновление
```bash
npm install
npm run vendor:copy
```
Скрипт: [`scripts/copy-vendor.mjs`](../scripts/copy-vendor.mjs). Пересобирает `public/assets/fonts/` и `public/vendor/plyr/` из `node_modules`.
Полная сборка (vendor + HTML):
```bash
npm run build
```
Только HTML из `src/`:
```bash
npm run build:pages
```
## Добавить или изменить шрифт
1. Установить пакет `@fontsource/...` или `@fontsource-variable/...`.
2. Добавить источник в `FONT_BUNDLES` в `copy-vendor.mjs`.
3. `npm run vendor:copy`.
4. Проверить имена `font-family` в [`public/css/main.css`](../public/css/main.css) (`html[data-font=...]`).
## Обновить Plyr
1. Обновить версию в `package.json` (`dependencies.plyr`).
2. `npm install` → `npm run vendor:copy`.
3. При смене API — правки в `video-player.js`.
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[];
p.on('pageerror', e=>errs.push('PE '+e.message));
p.on('console', m=>{ if(m.type()==='error') errs.push('CE '+m.text()); });
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount;
return { hasMount: !!m, speed: m?.currentSpeed, isVp: m?.isInViewport };
});
console.log('MOUNT', JSON.stringify(r));
console.log('ERRS', errs.slice(0,5));
await b.close();
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[];
p.on('pageerror', e=>errs.push('PE '+e.message));
p.on('console', m=>{ if(m.type()==='error') errs.push('CE '+m.text()); });
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1000));
const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{speed:m.currentSpeed,frame:m.getCurrentFrame(),inVp:m.isInViewport}:null; });
await new Promise(r=>setTimeout(r,2000));
const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{speed:m.currentSpeed,frame:m.getCurrentFrame(),inVp:m.isInViewport}:null; });
console.log('A',JSON.stringify(a),'C',JSON.stringify(c));
console.log('ANIMATING:', c && a && c.frame>a.frame && c.speed>0);
console.log('ERRS', errs.slice(0,5));
await b.close();
+12
View File
@@ -0,0 +1,12 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800)); // просто ждём, НЕ скроллим к секции
const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{frame:m.getCurrentFrame(),speed:m.currentSpeed,inVp:m.isInViewport}:null; });
await new Promise(r=>setTimeout(r,2000));
const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{frame:m.getCurrentFrame(),speed:m.currentSpeed,inVp:m.isInViewport}:null; });
console.log('A',JSON.stringify(a)); console.log('C',JSON.stringify(c));
console.log('ANIMATING:', !!c && c.frame>a.frame && c.speed>0);
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,500));
// достанем mount через глобальный хук: ShaderMount сохраняет себя в parentElement.paperShaderMount
const r = await p.evaluate(async()=>{
const wrap=document.querySelector('[data-shader-demo] .card-shader');
const m=wrap.paperShaderMount;
if(!m) return {hasMount:false};
const f1=m.getCurrentFrame();
await new Promise(r=>setTimeout(r,600));
const f2=m.getCurrentFrame();
return { hasMount:true, frame1:f1, frame2:f2, rafRunning: f2>f1, speed: m.speed, inViewport: m.isInViewport, currentSpeed: m.currentSpeed };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+19
View File
@@ -0,0 +1,19 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
const errs=[];
// проверим статус программы и шейдеров
const vs = gl.getProgramParameter(m.program, gl.LINK_STATUS);
// проверим, залит ли canvas хоть чем-то: clearColor потом readPixels
gl.clearColor(1,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
const px=new Uint8Array(4); gl.readPixels(10,10,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { linkStatus: vs, afterClearRed: Array.from(px), glError: gl.getError() };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+19
View File
@@ -0,0 +1,19 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
gl.getError(); // сброс
m.render(performance.now());
const e = gl.getError();
const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
// проверим a_position location
const aLoc = gl.getAttribLocation(m.program, 'a_position');
return { glErrorAfterRender: e, centerPx: Array.from(px), aPosLoc: aLoc, clearC: (()=>{const c=new Float32Array(4); gl.getClearColor?.(c); return Array.from(c);})() };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+24
View File
@@ -0,0 +1,24 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
const flat=[0.486,0.361,1,1, 0,0.761,0.78,1, 1,0.361,0.541,1, 1,0.702,0.278,1];
gl.useProgram(m.program);
gl.uniform4fv(gl.getUniformLocation(m.program,'u_colors'), new Float32Array(flat));
gl.uniform1f(gl.getUniformLocation(m.program,'u_colorsCount'),4);
gl.drawArrays(gl.TRIANGLES,0,6);
const W=m.canvasElement.width,H=m.canvasElement.height;
const pts=[[100,100],[360,100],[620,100],[100,220],[360,220],[620,220],[100,340],[360,340],[620,340]];
const out=pts.map(([x,y])=>{const px=new Uint8Array(4);gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);return [x,y,Array.from(px)];});
// макс яркость по всему canvas
const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let maxc=0; for(let i=0;i<buf.length;i++) maxc=Math.max(maxc,buf[i]);
return { samples:out, maxChannel:maxc };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+17
View File
@@ -0,0 +1,17 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const keys=['u_time','u_colors','u_colorsCount','u_distortion','u_swirl','u_grainMixer','u_grainOverlay'];
const out={};
keys.forEach(k=>{ const l=m.uniformLocations[k]; out[k]= l===null?'NULL':(l===undefined?'UNDEF':typeof l); });
// также посмотрим this.providedUniforms
const pu = m.providedUniforms ? Object.keys(m.providedUniforms) : 'none';
return { locations: out, providedKeys: pu };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
const loc0 = gl.getUniformLocation(m.program, 'u_colors');
const locB = gl.getUniformLocation(m.program, 'u_colors[0]');
// вручную зададим 4 цвета vec4
const flat = [0.486,0.361,1,1, 0,0.761,0.78,1, 1,0.361,0.541,1, 1,0.702,0.278,1];
gl.useProgram(m.program);
if (loc0) gl.uniform4fv(loc0, flat); else if (locB) gl.uniform4fv(locB, flat);
gl.drawArrays(gl.TRIANGLES,0,6);
const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { loc0null: loc0===null, locBnull: locB===null, centerAfterManual: Array.from(px) };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1400});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-shader-grid')?.scrollIntoView());
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const cards=[...document.querySelectorAll('#sec-shader-grid .neu-card--shader')];
const info=cards.map(c=>{
const sh=c.querySelector('.card-shader');
const m=sh?.paperShaderMount;
const cs=getComputedStyle(c);
const beforeZ=getComputedStyle(c,'::before').zIndex;
return { hasShaderWrap:!!sh, mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null, bg:cs.backgroundColor, beforeZ };
});
return { count: cards.length, info };
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,5));
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView());
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const cards=[...document.querySelectorAll('.card-shader[data-shader]')];
const info=cards.map(c=>({brand:c.dataset.shader, mounted:!!c.paperShaderMount, frag:c.paperShaderMount?.fragmentShader?.name||null}));
const f0=cards.find(c=>c.dataset.shader==='demo')?.paperShaderMount;
return { count: cards.length, info, demoFrame: f0?Math.round(f0.getCurrentFrame()):null,
demoAnimated: f0? (f0.getCurrentFrame()*0+ (f0.currentSpeed>0)) : null };
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,6));
await b.close();
+12
View File
@@ -0,0 +1,12 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push('PE: '+e.message)); p.on('console',m=>{if(m.type()==='error')errs.push('CON: '+m.text());});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const r = await p.evaluate(()=>{
const wrap=document.querySelector('[data-shader-demo] .card-shader');
return { hasMount: !!wrap?.paperShaderMount, scriptLoaded: !!window.__mountLabShaders };
});
console.log(JSON.stringify({r, errs}, null, 1));
await b.close();
+16 -22
View File
@@ -9,12 +9,11 @@
"plyr": "^3.8.4"
},
"devDependencies": {
"@fontsource-variable/geologica": "^5.2.8",
"@fontsource/alice": "^5.2.8",
"@fontsource/merriweather": "^5.2.11",
"@fontsource/pt-mono": "^5.2.8",
"@fontsource/pt-sans": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"medeo-font": "^1.0.0-rc.4.1",
"puppeteer": "^25.7.0",
"sharp": "^0.34.0"
}
@@ -30,16 +29,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@fontsource-variable/geologica": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geologica/-/geologica-5.2.8.tgz",
"integrity": "sha512-hmUBi4k8C0xfi/pk/ArAyK7kRPqbkV5o/YN4nmJ+mtc14HKaAzdWAKb31gFWpveI75POgtcw3/iAepcGRnotYw==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/alice": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/alice/-/alice-5.2.8.tgz",
@@ -70,16 +59,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pt-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/pt-sans/-/pt-sans-5.2.8.tgz",
"integrity": "sha512-b8nBGHMVwfe21IWqERlMrzIs+L/Vm3QNGbnET3H1vfclAScWExbN17N/z/dJcyPgvrcenbJDv1H3f5hTAEbcVQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/source-code-pro": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/source-code-pro/-/source-code-pro-5.2.7.tgz",
@@ -778,6 +757,21 @@
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
"license": "MIT"
},
"node_modules/medeo-font": {
"version": "1.0.0-rc.4.1",
"resolved": "https://registry.npmjs.org/medeo-font/-/medeo-font-1.0.0-rc.4.1.tgz",
"integrity": "sha512-W9n0tT4viqkLth4ZSE1BZwYnZWKr0fODqI2MBPRQQMgHrt24GA15Z6e1MirU9YzEO8DbzXegRe4QxGcGxp1vNg==",
"dev": true,
"license": "OFL-1.1",
"peerDependencies": {
"next": ">=13.2.0"
},
"peerDependenciesMeta": {
"next": {
"optional": true
}
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+2 -3
View File
@@ -9,15 +9,14 @@
"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"
"generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ medeo"
},
"devDependencies": {
"@fontsource-variable/geologica": "^5.2.8",
"@fontsource/alice": "^5.2.8",
"@fontsource/merriweather": "^5.2.11",
"@fontsource/pt-mono": "^5.2.8",
"@fontsource/pt-sans": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"medeo-font": "^1.0.0-rc.4.1",
"puppeteer": "^25.7.0",
"sharp": "^0.34.0"
},
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,500));
const r = await p.evaluate(()=>{
const wrap=document.querySelector('[data-shader-demo] .card-shader');
const m=wrap.paperShaderMount; const cv=wrap.querySelector('canvas');
const gl=cv.getContext('webgl2',{preserveDrawingBuffer:true})||cv.getContext('webgl',{preserveDrawingBuffer:true});
const read=()=>{ const a=new Uint8Array(4); gl.readPixels(cv.width/2|0,cv.height/2|0,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a); return Array.from(a); };
m.render(0); const t0=read();
m.render(3000); const t1=read();
m.render(8000); const t2=read();
return { t0, t1, t2 };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+26
View File
@@ -0,0 +1,26 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[],warns=[];
p.on('pageerror', e=>errs.push('PE '+e.message));
p.on('requestfailed', r=>errs.push('REQFAIL '+r.url()+' '+r.failure()?.errorText));
p.on('console', m=>{ const t=m.type(); if(t==='error') errs.push('CE '+m.text()); if(t==='warning') warns.push('W '+m.text()); });
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'networkidle2',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const info = await p.evaluate(()=>{
const cs=document.querySelector('[data-shader-demo] .card-shader');
const m=cs?.paperShaderMount;
const bg=getComputedStyle(cs).backgroundColor;
const canvas=cs?.querySelector('canvas');
return {
hasWrap: !!cs, hasMount: !!m,
wrapBg: bg, hasCanvas: !!canvas,
canvasW: canvas?.width, canvasH: canvas?.height,
speed: m?.currentSpeed, frame: m?Math.round(m.getCurrentFrame()):null,
};
});
console.log('INFO', JSON.stringify(info,null,1));
console.log('ERRS', errs.slice(0,8));
console.log('WARNS', warns.slice(0,8));
await b.close();
+52
View File
@@ -0,0 +1,52 @@
const puppeteer = require('puppeteer');
async function probePage(browser, url, width) {
const page = await browser.newPage();
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'networkidle0' });
await page.waitForSelector('[data-mini-projects-list] .mini-projects-grid__item', { timeout: 15000 }).catch(()=>{});
await new Promise(r => setTimeout(r, 700));
const data = await page.evaluate(() => {
const list = document.querySelector('[data-mini-projects-list]');
if (!list) return { none: true };
const inner = list.closest('.section__inner');
const items = [...list.querySelectorAll('.mini-projects-grid__item')].filter(i => !i.hidden);
const rowCells = (() => {
const w = list.clientWidth;
if (w >= 1180) return 6;
if (w >= 880) return 4;
if (w >= 520) return 2;
return 1;
})();
return {
listWidth: list.clientWidth,
innerWidth: inner ? inner.clientWidth : null,
rowCells,
itemCount: items.length,
items: items.map(i => ({
cells: i.getAttribute('data-mp-cells'),
styleW: i.style.width,
x: i.style.transform,
})),
};
});
await page.close();
return data;
}
(async () => {
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox','--disable-setuid-sandbox'] });
const widths = [600, 760, 900, 1024, 1180, 1278, 1356, 1600];
const caseUrl = 'http://localhost:4175/cases/ai-platform-primo-rpa/';
const homeUrl = 'http://localhost:4175/';
for (const w of widths) {
const c = await probePage(browser, caseUrl, w);
console.log(`CASE vw=${w} inner=${c.innerWidth} rowCells=${c.rowCells} items=${c.itemCount} ` + JSON.stringify(c.items));
}
console.log('---');
for (const w of widths) {
const h = await probePage(browser, homeUrl, w);
console.log(`HOME vw=${w} inner=${h.innerWidth} rowCells=${h.rowCells} items=${h.itemCount} ` + JSON.stringify(h.items));
}
await browser.close();
})().catch(e => { console.error(e); process.exit(1); });
+635
View File
@@ -0,0 +1,635 @@
<!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="Николай Мамадаев, AI-разработник. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты."
/>
<title>AI-разработка · Николай Мамадаев</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
medeo: "/assets/fonts/medeo.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 DEF = { h: 210, s: 90, l: 40 };
function pickAccent(o) {
if (!o) return null;
var h = typeof o.h === "number" ? o.h : typeof o.hue === "number" ? o.hue : null;
var s = typeof o.s === "number" ? o.s : typeof o.sat === "number" ? o.sat : null;
var l = typeof o.l === "number" ? o.l : typeof o.light === "number" ? o.light : null;
if (h === null && s === null && l === null) return null;
return {
h: h === null ? DEF.h : h,
s: s === null ? DEF.s : Math.min(100, Math.max(30, s)),
l: l === null ? DEF.l : Math.min(70, Math.max(20, l)),
};
}
// Текущий формат: отдельный акцент для светлой и тёмной темы ({light, dark}).
// Старый плоский формат ({h, s, l}) остаётся совместимым.
var acc =
pickAccent(eff === "dark" ? t.dark : t.light) ||
pickAccent(eff === "dark" ? t.light : t.dark) ||
pickAccent(t) ||
DEF;
d.style.setProperty("--theme-h", String(acc.h));
d.style.setProperty("--theme-s", String(acc.s));
d.style.setProperty("--theme-l", String(acc.l));
var H = (((acc.h % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, acc.s)) / 100;
var Lv = Math.min(100, Math.max(0, acc.l)) / 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 === "geologica") f = "medeo";
if (f === "onest") f = "medeo";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "medeo") f = "medeo";
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>
<!-- Аналитика грузится только на прод-домене: dev/preview (localhost:4175, dev.mamadaevv.site) в статистику не попадают. -->
<script>
(function () {
var PROD_HOSTS = ["mamadaevv.site", "www.mamadaevv.site"];
if (PROD_HOSTS.indexOf(location.hostname) === -1) return;
var s = document.createElement("script");
s.defer = true;
s.src = "https://cloud.umami.is/script.js";
s.setAttribute("data-website-id", "59e8e66e-2dab-4a86-acc6-b97985ad0473");
document.head.appendChild(s);
})();
</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="modulepreload" href="/js/modules/ui/page-mesh-shader.js" />
<link rel="stylesheet" href="/css/main.css" />
<link rel="canonical" href="https://mamadaevv.site/ai-dev/" />
<link rel="alternate" hreflang="ru" href="https://mamadaevv.site/ai-dev/" />
<link rel="alternate" hreflang="en" href="https://mamadaevv.site/ai-dev/" />
<link rel="alternate" hreflang="x-default" href="https://mamadaevv.site/ai-dev/" />
<link rel="describedby" href="https://mamadaevv.site/llms.txt" title="llms.txt" />
<meta name="author" content="Николай Мамадаев" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#0f1115" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Николай Мамадаев — продуктовый дизайнер" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:locale:alternate" content="en_US" />
<meta property="og:title" content="AI-разработка · Николай Мамадаев" />
<meta property="og:description" content="Николай Мамадаев, AI-разработник. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты." />
<meta property="og:url" content="https://mamadaevv.site/ai-dev/" />
<meta property="og:image" content="https://mamadaevv.site/assets/og/og-default.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="AI-разработка · Николай Мамадаев" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AI-разработка · Николай Мамадаев" />
<meta name="twitter:description" content="Николай Мамадаев, AI-разработник. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты." />
<meta name="twitter:image" content="https://mamadaevv.site/assets/og/og-default.png" />
<meta name="twitter:image:alt" content="AI-разработка · Николай Мамадаев" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://mamadaevv.site/#person",
"name": "Николай Мамадаев",
"url": "https://mamadaevv.site",
"jobTitle": "Продуктовый дизайнер",
"description": "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы.",
"knowsAbout": [
"UX/UI-дизайн",
"продуктовый дизайн",
"дизайн-системы",
"прототипирование",
"Figma",
"no-code"
],
"sameAs": [
"https://t.me/mamadaevv",
"https://vk.com/mamadaevv",
"https://github.com/mamadaevv"
]
},
{
"@type": "WebSite",
"@id": "https://mamadaevv.site/#website",
"url": "https://mamadaevv.site/",
"name": "Николай Мамадаев — продуктовый дизайнер",
"inLanguage": "ru",
"author": {
"@id": "https://mamadaevv.site/#person"
},
"publisher": {
"@id": "https://mamadaevv.site/#person"
}
},
{
"@type": "WebPage",
"@id": "https://mamadaevv.site/ai-dev/#page",
"url": "https://mamadaevv.site/ai-dev/",
"name": "AI-разработка · Николай Мамадаев",
"description": "Николай Мамадаев, AI-разработник. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты.",
"isPartOf": {
"@id": "https://mamadaevv.site/#website"
},
"author": {
"@id": "https://mamadaevv.site/#person"
},
"inLanguage": "ru"
},
{
"@type": "BreadcrumbList",
"@id": "https://mamadaevv.site/ai-dev/#breadcrumbs",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Николай Мамадаев — продуктовый дизайнер",
"item": "https://mamadaevv.site/"
},
{
"@type": "ListItem",
"position": 2,
"name": "AI-разработка · Николай Мамадаев",
"item": "https://mamadaevv.site/ai-dev/"
}
]
}
]
}
</script>
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
</head>
<body>
<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="medeo" 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">
<section class="section">
<div class="section__inner hero__grid">
<div>
<p class="hero__eyebrow">AI-разработка · идея, код, деплой</p>
<h1 class="hero__title">От идеи<br />до продукта.</h1>
</div>
<div>
<p class="case-page__sub">Проектирую, собираю и запускаю цифровые продукты.</p>
<p class="section__prose">
Больше десяти лет я проектировал интерфейсы. Теперь отвечаю за весь путь:
продуктовую логику, код, базу и инфраструктуру. Код часто пишет ИИ-агент.
Архитектуру и результат проверяю я.
</p>
<ul class="hero__tags" aria-label="Что я делаю">
<li class="hero__tag">Продуктовая логика и API</li>
<li class="hero__tag">Telegram-боты</li>
<li class="hero__tag">MCP-серверы</li>
<li class="hero__tag">Плагины и автоматизация</li>
</ul>
</div>
</div>
</section>
<section class="section section--tight">
<div class="section__inner">
<p class="hero__eyebrow">01 / Сделано и запущено</p>
<h2 id="projects" class="hero__title">Проекты</h2>
<ul class="filter-chips" data-filter-chips data-filter-scope="#ai-projects" aria-label="Фильтр проектов по типу">
<li><button type="button" class="filter-chip" data-filter-value="all" aria-pressed="true">Все</button></li>
<li><button type="button" class="filter-chip" data-filter-value="bots" aria-pressed="false">Боты</button></li>
<li><button type="button" class="filter-chip" data-filter-value="mcp" aria-pressed="false">MCP</button></li>
<li><button type="button" class="filter-chip" data-filter-value="plugins" aria-pressed="false">Плагины</button></li>
<li><button type="button" class="filter-chip" data-filter-value="web" aria-pressed="false">Веб</button></li>
<li><button type="button" class="filter-chip" data-filter-value="infra" aria-pressed="false">Инфраструктура</button></li>
</ul>
<ol class="project-grid" id="ai-projects">
<li data-filter-item data-filter-tag="bots">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-01.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Personal Assistant</h3>
<p class="mp-card__desc">Python · aiogram 3 · PostgreSQL</p>
<p class="mp-card__desc">Личный ассистент в Telegram: напоминания, планирование дел и работа с LLM в одном диалоге.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Боты</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="bots">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-02.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Image Gen Bot</h3>
<p class="mp-card__desc">Python · aiogram 3 · Redis · PostgreSQL</p>
<p class="mp-card__desc">Бот для генерации изображений: баланс, реферальная программа, админ-логи в отдельные топики супергруппы.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Боты</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="mcp bots">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-03.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MCP Image Server</h3>
<p class="mp-card__desc">JavaScript · MCP · OpenRouter</p>
<p class="mp-card__desc">Собственный MCP-сервер для генерации картинок. Подключается к агентам как инструмент.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">MCP</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="plugins">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-01.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<p class="mp-card__desc">TypeScript · Figma Plugin API</p>
<p class="mp-card__desc">Плагин для Figma, который исполняет скрипты прямо в редакторе. ИИ-ассистент для любого тарифа.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагины</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="plugins">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-02.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<p class="mp-card__desc">TypeScript · Obsidian API</p>
<p class="mp-card__desc">Вью для заметок в стиле канбан-доски: группировка по колонкам с фильтрами по тегам.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагины</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="plugins">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-03.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Hermes Desktop Plugins</h3>
<p class="mp-card__desc">JavaScript · TypeScript · Electron</p>
<p class="mp-card__desc">Плагины для рабочего стола Hermes: чип баланса API, звук завершения задачи и контроль трат.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагины</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="bots">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-01.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Social Post Writer</h3>
<p class="mp-card__desc">Python · aiogram 3 · SQLite</p>
<p class="mp-card__desc">Бот, который превращает тезисы в готовый пост под нужную площадку и тон, учитывая голос автора.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Боты</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="web">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-02.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">AI Design Assistant</h3>
<p class="mp-card__desc">HTML · CSS · GLSL · WebGL</p>
<p class="mp-card__desc">Этот сайт целиком: структура, дизайн-система, шейдеры, CI/CD. Весь цикл создания вместе с ИИ-агентом.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Веб</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="infra">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-03.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Секреты и менеджер паролей</h3>
<p class="mp-card__desc">Vaultwarden · Docker · Coolify</p>
<p class="mp-card__desc">Своё хранилище паролей с веб-доступом. Отдельный сервис, доступ к API закрыт ключом.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Инфраструктура</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="infra">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-01.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Прокси к хранилищу</h3>
<p class="mp-card__desc">Go · Vaultwarden API · all-cipher</p>
<p class="mp-card__desc">Форк Vaultwarden API с поддержкой всех типов шифрования. Нужен, чтобы читать секреты из агентов и скиллов.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Инфраструктура</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="infra">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-02.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Уведомления</h3>
<p class="mp-card__desc">Go · coturn · Matrix · ntfy</p>
<p class="mp-card__desc">Своя доставка уведомлений: ntfy для себя, Matrix-комната для агентов и WebRTC для звонков.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Инфраструктура</li>
</ul>
</div>
</article>
</li>
<li data-filter-item data-filter-tag="infra bots">
<article class="mp-card mp-card--stack" data-glass="false">
<div class="mp-card__media" aria-hidden="true">
<img class="mp-card__image" src="../assets/ai-dev/placeholder-03.webp" alt="" width="640" height="400" loading="lazy" decoding="async" />
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Бот для новостей</h3>
<p class="mp-card__desc">Python · aiogram 3 · PostgreSQL</p>
<p class="mp-card__desc">Telegram-бот, который присылает дайджест новостей по подпискам.</p>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Инфраструктура</li>
<li class="mp-card__tag">Боты</li>
</ul>
</div>
</article>
</li>
</ol>
</div>
</section>
<section class="section">
<div class="section__inner hero__grid">
<div>
<p class="hero__eyebrow">02 / Среда, которую я собрал</p>
<h2 id="infra" class="hero__title">Своя<br />инфраструктура.</h2>
</div>
<div>
<p class="case-page__sub">Два VPS. Сервисы под моим контролем.</p>
<p class="section__prose">
Разворачиваю проекты в Docker через Coolify. Traefik ведёт домены и TLS;
Gitea хранит код и запускает деплой. WireGuard соединяет серверы в закрытую
сеть. PostgreSQL и Redis обслуживают ботов и приложения.
</p>
<ul class="hero__tags" aria-label="Инструменты инфраструктуры">
<li class="hero__tag">Coolify</li>
<li class="hero__tag">Traefik</li>
<li class="hero__tag">Gitea</li>
<li class="hero__tag">WireGuard</li>
<li class="hero__tag">Docker</li>
<li class="hero__tag">PostgreSQL</li>
<li class="hero__tag">Redis</li>
<li class="hero__tag">XRay</li>
<li class="hero__tag">Python · aiogram</li>
<li class="hero__tag">TypeScript · JavaScript</li>
<li class="hero__tag">Go</li>
<li class="hero__tag">GLSL · WebGL</li>
</ul>
<p class="section__prose">
У сервисов отдельные схемы в общей базе. Между серверами ходит трафик
через WireGuard, наружу открываются только нужные точки входа.
</p>
</div>
</div>
</section>
<section class="section section--tight">
<div class="section__inner">
<p class="hero__eyebrow">03 / Код и проекты</p>
<h2 id="links" class="case-page__h1">Смотреть дальше</h2>
<ul class="case-links-grid">
<li>
<a class="case-link-card" href="https://github.com/mamadaevv" target="_blank" rel="noopener noreferrer">
<span class="case-link-card__body">
<span class="case-link-card__title">GitHub</span>
<span class="case-link-card__meta">Проекты и эксперименты с кодом</span>
</span>
</a>
</li>
<li>
<a class="case-link-card" href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin" target="_blank" rel="noopener noreferrer">
<span class="case-link-card__body">
<span class="case-link-card__title">Gitea</span>
<span class="case-link-card__meta">Внутренние проекты и сервисы</span>
</span>
</a>
</li>
<li>
<a class="case-link-card" href="../cases/site-portfolio/" target="_blank" rel="noopener noreferrer">
<span class="case-link-card__body">
<span class="case-link-card__title">Кейс этого сайта</span>
<span class="case-link-card__meta">Как я делал сайт вместе с ИИ-агентом</span>
</span>
</a>
</li>
<li>
<a class="case-link-card" href="https://secrets-4-me.dynv6.net" target="_blank" rel="noopener noreferrer">
<span class="case-link-card__body">
<span class="case-link-card__title">secrets-4-me</span>
<span class="case-link-card__meta">Мой менеджер паролей</span>
</span>
</a>
</li>
</ul>
</div>
</section>
</main>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
# ai.txt — mamadaevv.site
# Описание сайта для ИИ-агентов и ассистентов.
# Формат: https://github.com/dvetter/ai.txt
name: Портфолио Николая Мамадаева
description: >
Личный сайт продуктового дизайнера Николая Мамадаева. Кейсы UX/UI-проектов,
интерфейсов и дизайн-систем, резюме и контакты. Сайт доступен на русском и
английском языках.
author: Николай Мамадаев
contact: https://t.me/mamadaevv
canonical: https://mamadaevv.site/
sitemap: https://mamadaevv.site/sitemap.xml
# Разрешения для ИИ-агентов
policy: allow
allow: /
disallow: /_lab/
# Разрешённые сценарии использования
use: index
use: summarize
use: cite
use: reference
# Требование атрибуции
attribution: required
attribution_url: https://mamadaevv.site/
# Языки
languages: ru, en
default_language: ru
# Структура сайта
sections:
- path: /
title: Главная
description: Обо мне, портфолио и активность.
- path: /resume/
title: Резюме
description: Опыт работы, навыки и контакты.
- path: /cases/
title: Кейсы
description: Разборы UX/UI-проектов и дизайн-систем.
- path: /cookie-policy/
title: Политика cookie
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more