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

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