Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5bead9104 | ||
|
|
cd5aefbb14 | ||
|
|
a8346c2ec7 | ||
|
|
3e6ff48dac | ||
|
|
e19485e288 | ||
|
|
a30e0e21d9 | ||
|
|
288435baea | ||
|
|
8a1e775315 | ||
|
|
ce13814732 | ||
|
|
fb867596cc | ||
|
|
981f4b67af | ||
|
|
161f2efd13 | ||
|
|
1d19ea0eb8 | ||
|
|
f4404edaca | ||
|
|
be7da3d0ec | ||
|
|
f383ec350a | ||
|
|
ee0dca11a2 | ||
|
|
52485a17c4 | ||
|
|
39dad71bd1 | ||
|
|
bd0e562934 | ||
|
|
90b77e4433 | ||
|
|
c2ee622680 | ||
|
|
276c447567 | ||
|
|
8eeeb9eb0a | ||
|
|
f2bd745b70 | ||
|
|
6481fff41d | ||
|
|
caa08c8ad7 | ||
|
|
4e17471469 | ||
|
|
d8c9401381 | ||
|
|
ac1eceefd1 | ||
|
|
a8ae6323c0 | ||
|
|
1500849eb6 | ||
|
|
1f2ecaf415 | ||
|
|
90c012d580 | ||
|
|
e0f80d074d | ||
|
|
3aced0387b | ||
|
|
4c2d496343 | ||
|
|
ee9aafbd99 | ||
|
|
1695ad504d | ||
|
|
207e6121aa | ||
|
|
9fc2dcb02e | ||
|
|
1891a99ef1 | ||
|
|
dab79048f1 | ||
|
|
61230de426 | ||
|
|
93afa3814f | ||
|
|
8637ee6024 | ||
|
|
5b43d486c0 | ||
|
|
ac8b60049a | ||
|
|
908d5b259d | ||
|
|
6325186f52 | ||
|
|
eb90714012 | ||
|
|
8b50c821a7 | ||
|
|
d25c2e2185 | ||
|
|
0f8277dd0a | ||
|
|
052b5c4829 | ||
|
|
d41059d720 | ||
|
|
f702423445 | ||
|
|
8a63190f5a | ||
|
|
cd118c534f | ||
|
|
adf0f4b052 | ||
|
|
02f05c0598 | ||
|
|
a9957e739f | ||
|
|
3a4537209b | ||
|
|
fd8e524dba | ||
|
|
457fff314b | ||
|
|
799878c891 | ||
|
|
c8a9c82bfa | ||
|
|
03a8e73ea6 | ||
|
|
a77033e71f | ||
|
|
3388268697 | ||
|
|
9608e24ae8 | ||
|
|
2267a35698 | ||
|
|
9f617c581a | ||
|
|
a692278267 | ||
|
|
62c0a8f044 | ||
|
|
8c34d3fa97 | ||
|
|
3d5fbe45ee | ||
|
|
61589176b2 | ||
|
|
09111a946c | ||
|
|
57146ec64a | ||
|
|
0baf415e13 | ||
|
|
69c9223401 | ||
|
|
4b2fe4fe9c | ||
|
|
6d064e53b9 | ||
|
|
daa623fe73 | ||
|
|
b38f1a5332 | ||
|
|
11e24da9e2 | ||
|
|
903d222324 | ||
|
|
e410a2975f | ||
|
|
372ccfbf60 | ||
|
|
c69fc8f9c0 | ||
|
|
14b7cd829b | ||
|
|
220fb634fc | ||
|
|
df2ef8b7b6 | ||
|
|
6aeb5a378f | ||
|
|
94de132cf0 | ||
|
|
15bf0e3293 | ||
|
|
449d5ac782 | ||
|
|
2e6451e683 | ||
|
|
5fd8324b47 | ||
|
|
e3acfa072c | ||
|
|
2b7361664e | ||
|
|
b6161c92cd | ||
|
|
939ce8fcc9 | ||
|
|
9d78567386 | ||
|
|
1bb50b46fb | ||
|
|
4dd9c97481 | ||
|
|
562348cf1e | ||
|
|
be4cf20a0f | ||
|
|
7aab957290 | ||
|
|
7bb7fca7ae | ||
|
|
cb24acd3dd | ||
|
|
e325cd9173 | ||
|
|
c8ffee1136 | ||
|
|
ca7c4c2288 | ||
|
|
05b82b08a0 | ||
|
|
e8c27898a7 | ||
|
|
66ab7d98be | ||
|
|
a8bc87dff5 | ||
|
|
10f94cc306 | ||
|
|
d652a63dda | ||
|
|
971fe6013f | ||
|
|
82e0e0d67a | ||
|
|
b596a0e80a | ||
|
|
32d5d82db9 | ||
|
|
45d5b18916 | ||
|
|
7e75d728a2 | ||
|
|
f9c8029849 | ||
|
|
06f65953aa | ||
|
|
9e62a1ea53 | ||
|
|
e60d791934 | ||
|
|
7d2a213491 | ||
|
|
5ce455d81b | ||
|
|
2d67a5be5a | ||
|
|
32fecdb3e4 | ||
|
|
15c7bbc741 | ||
|
|
6b4e20c502 | ||
|
|
f947305a09 | ||
|
|
8caeb3147c | ||
|
|
b9d98a72df | ||
|
|
b935640268 | ||
|
|
5bb8dc8ad2 | ||
|
|
6d66e4cf12 | ||
|
|
5f07f925f2 | ||
|
|
9f1b6c6f15 | ||
|
|
588e3a2b9d | ||
|
|
f1f1ead1f8 | ||
|
|
0c172aecfe | ||
|
|
464816fba7 | ||
|
|
3e5ec5b564 | ||
|
|
e29a0619b2 | ||
|
|
1ce19f9e69 |
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` задействован также вне каталога портфолио; при реализации нужно сохранить существующий стековый вариант там, где он уже используется, добавив горизонтальный лэйаут в рамках уже существующих классов, без нового имени класса.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
### Блоки лонгрида
|
||||
|
||||
| Класс | Назначение |
|
||||
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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`)
|
||||
|
||||
### Выходной файл
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Подключение:
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
"translate": "node scripts/translate.mjs",
|
||||
"convert-videos": "node scripts/convert-videos.mjs",
|
||||
"optimize-images": "node scripts/optimize-images.mjs",
|
||||
"generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica"
|
||||
"generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ medeo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/geologica": "^5.2.8",
|
||||
"@fontsource/alice": "^5.2.8",
|
||||
"@fontsource/merriweather": "^5.2.11",
|
||||
"@fontsource/pt-mono": "^5.2.8",
|
||||
"@fontsource/pt-sans": "^5.2.8",
|
||||
"@fontsource/source-code-pro": "^5.2.7",
|
||||
"medeo-font": "^1.0.0-rc.4.1",
|
||||
"puppeteer": "^25.7.0",
|
||||
"sharp": "^0.34.0"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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); });
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 594 B |
|
After Width: | Height: | Size: 526 B |
|
After Width: | Height: | Size: 594 B |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 67 KiB |