Merge feature/liquid-glass: Liquid Glass эффект (displacement + матирование)

# Conflicts:
#	Dockerfile
This commit is contained in:
root committed 2026-09-24 21:29:53 +02:00
commit b38f1a5332
288 files changed
+50826 -6527

No files matched your search

File diff suppressed because it is too large. Load diff
+1
View File
@@ -12,6 +12,7 @@ coverage/
!.cursor/commands/
!.cursor/commands/**
.vscode/
.zed/
gitverse/
# Logs
+1
View File
@@ -0,0 +1 @@
22
+40 -19
View File
@@ -1,27 +1,18 @@
# Подсказки для агента (Cursor)
## GitVerse / деплой статики
## Сборка и деплой
Полный чеклист: **`docs/gitverse-sync.md`** → раздел **«Инструкция для агента (Cursor)»**.
Деплой автоматический: пуш в git → автодеплой на хостинг.
Деплой на VPS: **`docs/vps-deploy.md`**.
По запросу пользователя «собрать», «билд»:
Кратко по запросу пользователя «выложить на GitVerse», «синк gitverse», «задеплоить»:
1. При правках `src/pages/**` или `src/partials/**` — `npm run build:pages`.
2. При смене шрифтов/Plyr или `package.json` — `npm run build` (включает `vendor:copy`).
3. Для деплоя — сделать git commit + push.
1. При правках `src/pages/**` или `src/partials/**` — `npm run build:pages`; при смене шрифтов/Plyr или `package.json` — `npm run build` (включает `vendor:copy`).
2. Затем `npm run sync-gitverse` (разрешения **network** и **git_write**).
3. Не запускать синк без явного запроса пользователя.
## Git
По запросу «задеплой в Coolify», «задеплой на VPS»:
1. При правках в `public/` — `npm run deploy:vps` (разрешения **all**).
2. По запросу «задеплой везде» — `npm run deploy` (VPS + GitVerse).
Не запускать деплой на VPS без явного запроса: скрипт перезаписывает `/data/static-site` на сервере.
## Git: корневой репозиторий
Этот репозиторий в корне проекта **только локальный** (рабочая копия, история коммитов для себя). **Не добавлять `origin` и не пушить** в удалённый репозиторий из агента, если пользователь явно не попросил настроить remote и не дал URL. Выгрузка наружу — отдельный сценарий через **`npm run sync-gitverse`** в папку `gitverse` (см. выше), а не `git push` из корня.
Репозиторий: `http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/site-portfolio.git`
### Влить в `master`, оставаясь на текущей ветке
@@ -33,9 +24,14 @@
## Design System
Страница-каталог: **`src/pages/cases/design-system.html`** → после сборки `public/cases/design-system.html`.
Перед работой с UI **прочитай `DESIGN.md`** в корне проекта — там описаны токены, типографика, компоненты и правила их применения.
При **глобальных** правках стилей (токены в `public/css/main.css`, компоненты главной, блоки лонгрида кейса) **обновляй Design System**: живой пример и краткое описание в соответствующей секции, при необходимости — `docs/case-page-blocks.md`.
Страницы-каталоги (живые примеры):
- **`src/pages/cases/design-system.html`** → токены, типографика, кнопки, карточки, секции
- **`src/pages/cases/template-elements-demo.html`** → элементы лонгрида: медиа, галереи, видео, callout
- **`src/pages/cases/mini-projects-showcase.html`** → карточки активности (mp-card)
При **глобальных** правках стилей (токены в `public/css/main.css`, компоненты главной, блоки лонгрида кейса) **обновляй соответствующий каталог**: живой пример и краткое описание в секции, при необходимости — `docs/case-page-blocks.md`.
## Контент кейса в public (по запросу)
@@ -49,6 +45,18 @@
Режим на странице при `npm run serve` → очередь в **`texts/content-edits/pending.json`**. Apply по запросу **«примени правки»**: читать этот файл, править **`src/pages/`**, **humanizer-ru**, **`npm run build:pages`**; **`pending.json` не очищать** (пользователь удаляет пункты сам). Подробности: **`docs/content-edit-mode.md`**, **`.cursor/rules/content-edit-apply.mdc`**.
## PDF резюме (по запросу)
По запросу «обнови PDF», «перегенерируй PDF»:
1. Убедиться, что dev-сервер запущен (`localhost:4175`).
2. Запустить `npm run generate:resume-pdf`.
3. Файл обновится в `public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf`.
Подробнее: **`docs/resume-pdf.md`**.
Скрипт требует запущенный сервер — нельзя встроить в обычный `build`.
## Vendor (шрифты, Plyr)
Локально в `public/`, без CDN. Обновление: **`docs/vendor-assets.md`**, команда `npm run vendor:copy`.
@@ -60,3 +68,16 @@
## Остальное
Правила и таблица доков: **`.cursor/rules/read-docs-before-feature-dev.mdc`** (локально; папка `.cursor/` может быть в `.gitignore`).
### Ключевые файлы
| Файл | Назначение |
|---|---|
| `DESIGN.md` | Описание дизайн-системы для AI-агентов |
| `public/css/main.css` | Единый CSS: токены + все компоненты |
| `src/pages/cases/design-system.html` | Живой каталог токенов, типографики, кнопок, карточек |
| `src/pages/cases/template-elements-demo.html` | Элементы лонгрида: медиа, галереи, видео, callout |
| `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) |
| `docs/case-page-blocks.md` | Инструкция по блокам лонгридов |
| `docs/resume-pdf.md` | Генерация PDF резюме (Puppeteer) |
| `docs/translation.md` | Локализация (ru/en) и перевод через apinex |
+247
View File
@@ -0,0 +1,247 @@
# DESIGN.md
Портфолио-сайт продуктового дизайнера. Лонгриды кейсов, резюме, блог. Визуал — минимализм с акцентом на типографику и контент.
---
## Продукт
Одностраничный сайт-портфолио с внутренними страницами (кейсы, резюме, блог). Основной контент — лонгриды с описанием проектов. Навигация через главную страницу с секциями: навыки, портфолио, контакты.
**Характер:** Сдержанный, профессиональный. Без визуального шума. Контент — главный элемент интерфейса.
---
## Цвета
### Токены темы
| Токен | Назначение |
|---|---|
| `--theme-h` | Оттенок акцента (0–360°) |
| `--theme-s` | Насыщенность акцента |
| `--theme-l` | Светлота акцента |
| `--accent` | Основной акцентный цвет |
| `--accent-text` | Цвет текста на акцентном фоне |
| `--accent-soft` | Мягкий акцент для фона |
| `--bg` | Фон страницы |
| `--bg-elevated` | Поверхность второго уровня |
| `--surface` | Фон карточек и секций |
| `--text` | Основной текст |
| `--text-muted` | Приглушённый текст |
| `--line` | Рамки и разделители |
### Правила
- Акцент меняется через `--theme-h/s/l` на `<html>`. Все производные пересчитываются автоматически.
- Светлая/тёмная тема — `data-theme="light|dark"` на `<html>`.
- Нейтральные фоны (`--bg`, `--surface`) слегка подмешивают оттенок акцента через `--neutral-accent-tint`.
- Не используйте хардкод hex-значений. Всегда обращайтесь к токенам.
---
## Типографика
### Семейства
| `data-font` | Стек | Назначение |
|---|---|---|
| `merriweather` | Merriweather + Alice | По умолчанию. Alice — проза, Merriweather — заголовки |
| `geologica` | Geologica + PT Sans | Гротеск + нейтральная проза |
| `mono` | Source Code Pro + PT Mono | Моноширинный |
### Шкала
| Класс | Размер | Вес | Назначение |
|---|---|---|---|
| `.hero__eyebrow` | `0.78rem` | 700 | Подзаголовок секции, ввод кейса. Uppercase, letter-spacing 0.14em |
| `.hero__title` | `clamp(3.1rem, 7.2vw, 4.85rem)` | 700 | H1 главной страницы |
| `.case-page__h1` | `clamp(2rem, 3.9vw, 2.9rem)` | 700 | H1 кейса / внутренней страницы |
| `.case-page__sub` | `clamp(1.5rem, 2vw, 1.65rem)` | 500 | Подзаголовок под H1 |
| `.section__title` | `clamp(0.95rem, 1.35vw, 1.1rem)` | 600 | Заголовки секций («Навыки», «Портфолио») |
| `.section__prose` | наследует | — | Основной текст абзацев |
| `h2` в `.case-article` | `clamp(1.5rem, 2vw, 1.65rem)` | 500 | Разделы лонгрида |
| `h3` в `.case-article` | наследует | — | Подразделы внутри H2 |
### Правила
- Заголовки используют `clamp()` — размер масштабируется от вьюпорта.
- Не задавайте `font-size` в пикселях. Используйте `rem` или `clamp()`.
- `line-height` для прозы: `1.55` (базовый), для заголовков: `1.2`.
- `max-width` текстовой колонки: `42rem` (`.section__prose`).
---
## Отступы и сетка
### Токены
| Токен | Значение | Назначение |
|---|---|---|
| `--space` | `clamp(1rem, 4vw, 2.75rem)` | Горизонтальные поля + safe-area |
| `--max` | `none` | Ширина контента = вьюпорт минус padding |
| `--radius-sm` | Зависит от `data-radius` | Малые скругления |
| `--radius-md` | Зависит от `data-radius` | Средние скругления |
| `--radius-card` | Зависит от `data-radius` | Карточки |
### Секции
| Класс | Назначение |
|---|---|
| `.section` | Вертикальные отступы через `clamp()` |
| `.section--tight` | Компактные отступы (кейсы, внутренние страницы) |
| `.section--surface` | Прозрачный фон (для mesh на главной) |
| `.section__inner` | Центрированная обёртка с `max-width: var(--max)` |
| `.case-article` | Контентная колонка лонгрида: `max-width: 48rem` |
### Брейкпоинты
| Ширина | Что меняется |
|---|---|
| `860px` | Hero → 2 колонки (`1.15fr 0.85fr`) |
| `760px` / `1180px` | Карусель портфолио: 1 → 2 → 3 карточки |
| `720px` | Сетка навыков → 2 колонки |
| `1024px` | Навыки: 6-колоночная мозаика |
| `560px` / `1024px` | Контакты → 2 → 4 колонки |
| `640px` | Wide-блоки схлопываются в ширину колонки |
### Правила
- Отдельной 12-колоночной сетки нет. Используйте CSS Grid внутри секций.
- Горизонтальные отступы — `--space` + `safe-area-inset`.
- Wide-блоки (`.case-media--wide`, `.case-callout`) шире колонки: `min(100vw - 2*padding, content + 8rem)`.
---
## Компоненты
### Кнопки
| Класс | Назначение |
|---|---|
| `.btn` | Базовый класс кнопки |
| `.btn--primary` | Основная кнопка (акцентный фон, стекло) |
| `.btn--secondary` | Вторичная кнопка (нейтральное стекло) |
| `.btn--ghost` | Текстовая кнопка без фона |
| `.btn--flat` | Плоский вариант (без «объёма») |
| `.btn--raised` | Приподнятый вариант (с тенью) |
| `.btn__icon` | Иконка внутри кнопки |
### FAB-кнопки
| Класс | Назначение |
|---|---|
| `.fab` | Базовый класс плавающей кнопки (круглая, `position: fixed`) |
| `.fab--primary` | Акцентная FAB (градиент, стекло, `--on-accent` для текста/иконки) |
| `.fab--secondary` | Вторичная FAB (нейтральная, для настроек) |
| `.fab--label` | Модификатор: снимает фиксированную ширину, добавляет текст рядом с иконкой |
| `.fab-primary__ico` | Иконка внутри primary FAB (маска через `mask-image`) |
| `.fab__label` | Текстовая подпись внутри FAB |
Правило: `--on-accent` вычисляется в синхронном `theme-init-script` (в `<head>`) по luminance акцента. Не задавайте `color` вручную — наследуется от `.fab--primary`.
### Теги
| Класс | Назначение |
|---|---|
| `.hero__tags` | Контейнер тегов в Hero |
| `.hero__tag` | Тег (пилл с рамкой). Используйте для ключевых слов |
| `.portfolio-card__tag` | Тег внутри карточки портфолио |
### Карточки
| Класс | Назначение |
|---|---|
| `.portfolio-card` | Карточка проекта в портфолио |
| `.portfolio-card__body` | Текстовая часть |
| `.portfolio-card__cover` | Обложка |
| `.skill-task-card` | Карточка навыка (задача + решения) |
| `.contact-card` | Контактная карточка (ссылка или действие) |
### Блоки лонгрида
| Класс | Назначение |
|---|---|
| `.hero__eyebrow` | Подзаголовок перед H1 |
| `.case-page__h1` | Заголовок страницы |
| `.case-page__sub` | Подзаголовок |
| `.section__prose` | Абзац текста |
| `.section__title` | Заголовок секции |
| `.case-callout` | Акцентный блок (важная мысль) |
| `.case-media` | Обёртка для изображения/видео |
| `.case-media--natural` | Изображение в натуральном масштабе |
| `.case-media--content` | По ширине текстовой колонки |
| `.case-media--wide` | Шире колонки статьи |
| `.case-media-gallery--wide` | Шире колонки статьи (галерея) |
| `.case-media-slider` | Слайдер: одна картинка, стрелки и точки-пагинация |
---
## Поведение
### Изображения
- Формат: **WebP** для всех изображений.
- Видео: **WebM** (MP4 как fallback).
- Lazy loading: `loading="lazy"`, `decoding="async"`.
- Просмотр по клику: `data-image-viewer` на `<img>`.
- Заглушка при загрузке: `.is-img-loading` / `.has-img-loading`.
### Секции
- Порядок на странице кейса: `header-case` → `fab-stack` → `main.case-article` → `projects-section` → `contact-section` → `footer`.
- Порядок на внутренних страницах: `header-minimal` → `fab-stack-home-contact` → `main.section` → `footer`.
### Навигация
- Главная: горизонтальная навигация по секциям.
- Внутренние страницы: ссылка «на главную» в шапке.
- Плавающие кнопки (FAB): тема, контакты.
---
## Do's and Don'ts
### Do's
- Используйте токены цвета (`--accent`, `--text`, `--surface`), не хардкодьте.
- Используйте `clamp()` для типографики — не фиксированные px/rem.
- Используйте `.section__prose` для основного текста — он задаёт `max-width` и `color`.
- Используйте `.hero__eyebrow` для подзаголовков перед заголовками.
- Используйте WebP для изображений, WebM для видео.
- Для лонгридов внутри `.case-article` — заголовки `h2`/`h3` стилизуются автоматически.
### Don'ts
- Не создавайте новые классы для типографики, если есть готовые в таблице шкалы.
- Не используйте `font-size` в пикселях.
- Не смешивайте стили из разных `data-font` на одной странице.
- Не используйте `.hero__tag` для навыков в резюме — используйте `.skill-card` или простой список.
- Не создавайте wide-блоки без необходимости — они выходят за колонку и ломают ритм.
- Не используйте декоративные градиенты на функциональных поверхностях.
---
## Страницы
| Страница | Шаблон | Ключевые компоненты |
|---|---|---|
| Главная | `src/pages/index.html` | Hero, Skills, Projects, Contact |
| Кейс | `src/pages/cases/*.html` | `.case-article`, `.hero__eyebrow`, `.case-page__h1`, `.case-media` |
| Резюме | `src/pages/resume*.html` | `.doc`, `.profile-header`, `.meta-grid`, `.timeline`, `.skill-grid` |
| Design System | `src/pages/cases/design-system.html` | Живой каталог компонентов и токенов |
| Demo elements | `src/pages/cases/template-elements-demo.html` | Элементы лонгрида: медиа, галереи, видео, callout |
| Mini projects | `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) |
---
## Файлы
| Файл | Назначение |
|---|---|
| `public/css/main.css` | Единый CSS: токены + все компоненты |
| `src/pages/cases/design-system.html` | Живой каталог токенов, типографики, кнопок, карточек |
| `src/pages/cases/template-elements-demo.html` | Элементы лонгрида: медиа, галереи, видео, callout |
| `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) |
| `docs/case-page-blocks.md` | Инструкция по блокам лонгридов |
| `DESIGN.md` | Этот файл — описание для AI-агентов |
+1
View File
@@ -0,0 +1 @@
Мой сайт портфолио
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+18
View File
@@ -0,0 +1,18 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({ args: ["--no-sandbox","--disable-dev-shm-usage"] });
const p = await b.newPage();
await p.setViewport({ width: 768, height: 900, deviceScaleFactor: 1 });
await p.goto("http://localhost:4175/", { waitUntil: "networkidle0" });
await p.evaluate(() => window.scrollTo(0, 400));
await new Promise(r=>setTimeout(r,400));
await p.evaluate(() => document.querySelector("[data-nav-more-btn]")?.click());
await new Promise(r=>setTimeout(r,300));
const m = await p.evaluate(() => {
const menu = document.querySelector("#nav-more-menu-floating");
if (!menu || menu.hidden) return { hidden: true };
const mr = menu.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
return { rightFromEdge: Math.round(vw - mr.right), edgePad: getComputedStyle(document.documentElement).getPropertyValue("--edge-pad").trim() };
});
console.log("MENU@768:", JSON.stringify(m));
await b.close();
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1500});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView());
await new Promise(r=>setTimeout(r,1500));
const r = await p.evaluate(()=>{
const cells=[...document.querySelectorAll('[data-shader-cell]')];
const cellInfo=cells.map(c=>{const m=c.querySelector('.card-shader')?.paperShaderMount; return {key:c.dataset.shaderCell, mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null};});
const demo=document.querySelector('[data-shader-demo]');
const demoMounted=!!demo.querySelector('.card-shader')?.paperShaderMount;
// переключим на spiral и проверим label
const btn=document.querySelector('[data-shader-switch="spiral"]');
let labelAfter=null;
if(btn){ btn.click(); labelAfter=document.querySelector('[data-shader-label]')?.textContent; }
return { cellCount: cells.length, cellInfo, demoMounted, labelAfter };
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,8));
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,600));
const snap = ()=>p.evaluate(()=>{
const cv=document.querySelector('[data-shader-demo] canvas');
const gl=cv.getContext('webgl2',{preserveDrawingBuffer:true})||cv.getContext('webgl',{preserveDrawingBuffer:true});
const a=new Uint8Array(4*9); let k=0;
for(const [x,y] of [[20,20],[100,80],[200,150],[50,180],[300,40]]){ gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a.subarray(k,k+4)); k+=4; }
return Array.from(a).join(',');
});
const s1=await snap(); await new Promise(r=>setTimeout(r,700)); const s2=await snap();
console.log('frame1:', s1);
console.log('frame2:', s2);
console.log('MOVING:', s1!==s2);
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,600));
async function shot(){ const el=await p.$('[data-shader-demo]'); await el.screenshot({path:'/tmp/a.png'});
const {data,info}=await sharp('/tmp/a.png').raw().toBuffer({resolveWithObject:true});
return {w:info.width,h:info.height,data}; }
const s1=await shot(); await new Promise(r=>setTimeout(r,800)); const s2=await shot();
// сравним центральные пиксели
function center(d,info){ const i=((info.height>>1)*info.width+(info.width>>1))*3; return [d[i],d[i+1],d[i+2]]; }
const c1=center(s1.data,s1), c2=center(s2.data,s2);
console.log('center px t1:', c1, ' t2:', c2, ' MOVED:', JSON.stringify(c1)!==JSON.stringify(c2));
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-150), top);
await new Promise(r=>setTimeout(r,700));
async function region(){ await p.screenshot({path:'/tmp/r.png'});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const {data,info}=await sharp('/tmp/r.png').raw().toBuffer({resolveWithObject:true});
// хеш центральной полосы
const samples=[]; const y=Math.floor(rect.y+rect.h*0.4);
for(let x=Math.floor(rect.x+20); x<rect.x+rect.w-20; x+=15){ const i=(Math.floor(y)*info.width+Math.floor(x))*3; samples.push(data[i]+','+data[i+1]+','+data[i+2]); }
return samples.join('|');
}
const a=await region(); await new Promise(r=>setTimeout(r,800)); const c=await region();
console.log('moved:', a!==c);
console.log('t1 head:', a.slice(0,60)); console.log('t2 head:', c.slice(0,60));
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import crypto from 'crypto';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-150), top);
await new Promise(r=>setTimeout(r,700));
async function hash(){ await p.screenshot({path:'/tmp/rl.png'});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const {data,info}=await sharp('/tmp/rl.png').raw().toBuffer({resolveWithObject:true});
const x0=Math.floor(rect.x),y0=Math.floor(rect.y),x1=Math.floor(rect.x+rect.w),y1=Math.floor(rect.y+rect.h);
const buf=Buffer.alloc((x1-x0)*(y1-y0)*3);
let k=0; for(let y=y0;y<y1;y++) for(let x=x0;x<x1;x++){ const i=(y*info.width+x)*3; buf[k++]=data[i];buf[k++]=data[i+1];buf[k++]=data[i+2]; }
return crypto.createHash('md5').update(buf).digest('hex');
}
const a=await hash(); await new Promise(r=>setTimeout(r,3000)); const c=await hash();
console.log('hash1', a); console.log('hash2', c); console.log('ANIMATED:', a!==c);
await b.close();
+26
View File
@@ -0,0 +1,26 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:800,height:600});
await p.goto('about:blank',{waitUntil:'load'});
await new Promise(r=>setTimeout(r,300));
const r = await p.evaluate(()=>{
const cv=document.createElement('canvas'); cv.width=64; cv.height=64; document.body.appendChild(cv);
const gl=cv.getContext('webgl2');
if(!gl) return {webgl2:false};
const vs=`#version 300 es\nin vec4 a_position; void main(){ gl_Position=a_position; }`;
const fs=`#version 300 es\nprecision mediump float; out vec4 o; void main(){ o=vec4(1.0,0.3,0.0,1.0); }`;
const mk=(s)=>{const sh=gl.createShader(s.includes('main(){ gl_Position')?gl.VERTEX_SHADER:gl.FRAGMENT_SHADER);gl.shaderSource(sh,s);gl.compileShader(sh);return {sh,ok:gl.getShaderParameter(sh,gl.COMPILE_STATUS),log:gl.getShaderInfoLog(sh)};};
const v=mk(vs), f=mk(fs);
const prog=gl.createProgram(); gl.attachShader(prog,v.sh); gl.attachShader(prog,f.sh); gl.linkProgram(prog);
const linked=gl.getProgramParameter(prog,gl.LINK_STATUS);
gl.useProgram(prog);
const buf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buf); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
const loc=gl.getAttribLocation(prog,'a_position'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES,0,6);
const px=new Uint8Array(4); gl.readPixels(32,32,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { webgl2:true, vsOk:v.ok, fsOk:f.ok, fsLog:f.log, linked, center:Array.from(px) };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-150), top);
await new Promise(r=>setTimeout(r,800));
await p.screenshot({path:'/tmp/sec.png'});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const {data,info}=await sharp('/tmp/sec.png').raw().toBuffer({resolveWithObject:true});
const x0=Math.floor(rect.x), y0=Math.floor(rect.y), x1=Math.floor(rect.x+rect.w), y1=Math.floor(rect.y+rect.h);
let maxR=0,maxG=0,maxB=0, brightPx=0, n=0;
for(let y=Math.max(0,y0);y<Math.min(info.height,y1);y++) for(let x=Math.max(0,x0);x<Math.min(info.width,x1);x++){ const i=(y*info.width+x)*3; maxR=Math.max(maxR,data[i]);maxG=Math.max(maxG,data[i+1]);maxB=Math.max(maxB,data[i+2]); if(data[i]+data[i+1]+data[i+2]>250) brightPx++; n++; }
console.log('max channel values in demo card:', maxR, maxG, maxB, 'bright pixels:', brightPx, '/', n);
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1500});
await p.goto('http://10.200.201.3:4175/',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(()=>{
const pick = (sel)=>{ const el=document.querySelector(sel); if(!el) return null;
const cs=getComputedStyle(el);
return { boxShadow: cs.boxShadow.slice(0,60), shadowLen: cs.boxShadow.length, transform: cs.transform, bg: cs.backgroundImage.slice(0,40) }; };
return {
mp: pick('.mp-card'),
portfolio: pick('.portfolio-card'),
skill: pick('.skill-task-card'),
theme: document.documentElement.getAttribute('data-theme'),
};
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,6));
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{ if(m.type()==='error') errs.push(m.text()); });
p.on('pageerror',e=>errs.push('PAGEERR '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const cards = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>{
const cs = getComputedStyle(e);
const before = getComputedStyle(e,'::before');
const img = e.querySelector('img');
return { name:e.querySelector('.contact-card__name')?.textContent,
boxShadow: cs.boxShadow.slice(0,40),
beforeBg: before.backgroundImage.slice(0,60),
borderW: cs.borderTopWidth,
imgLoaded: img ? (img.complete && img.naturalWidth>0) : 'no-img' };
}));
console.log(JSON.stringify({count:cards.length, cards, errs}, null, 1));
await b.close();
+13
View File
@@ -0,0 +1,13 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{ if(m.type()==='error') errs.push(m.text()); });
p.on('pageerror',e=>errs.push('PAGEERR '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
// dark
const dark = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>({n:e.querySelector('.contact-card__name')?.textContent, bs:e.style.boxShadow?'inline':'', shadow:getComputedStyle(e).boxShadow.length})));
// light theme
await p.evaluate(()=>document.documentElement.setAttribute('data-theme','light'));
const light = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>({n:e.querySelector('.contact-card__name')?.textContent, shadow:getComputedStyle(e).boxShadow.slice(0,30)})));
console.log(JSON.stringify({darkCount:dark.length, light, errs}, null, 1));
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1400});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const r = await p.evaluate(()=>{
const shaderCards=[...document.querySelectorAll('.card-shader[data-shader]')];
const mounted=shaderCards.map(w=>!!w.paperShaderMount);
return {
hasSecShader: !!document.querySelector('#sec-shader'),
hasSecShaderGrid: !!document.querySelector('#sec-shader-grid'),
hasSecContact: !!document.querySelector('#sec-contact'),
totalShaderWraps: shaderCards.length,
allMounted: mounted.every(Boolean),
mountedCount: mounted.filter(Boolean).length,
};
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,5));
await b.close();
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
await p.setViewport({width:1280,height:1000});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e = document.querySelector(s);
const cs = getComputedStyle(e);
const bs = cs.boxShadow.split('),').map(x=>x.trim());
return {
bg: cs.backgroundColor,
boxShadow: bs,
};
}, sel);
const neu = await probe('#sec-final .neu-card');
const cc = await probe('#sec-contact .contact-card');
console.log('=== NEU-CARD (Финал) ===');
console.log(JSON.stringify(neu,null,1));
console.log('=== CONTACT-CARD ===');
console.log(JSON.stringify(cc,null,1));
await b.close();
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e = document.querySelector(s); const cs=getComputedStyle(e);
return { bg: cs.backgroundColor, bgImg: cs.backgroundImage.slice(0,55), shadow: cs.boxShadow.split('),').map(x=>x.trim().replace(/rgba\([^)]*\)/,'C')) };
}, sel);
console.log('DARK');
console.log(' neu:', JSON.stringify(await probe('#sec-final .neu-card')));
console.log(' cc :', JSON.stringify(await probe('#sec-contact .contact-card')));
await p.evaluate(()=>document.documentElement.setAttribute('data-theme','light'));
console.log('LIGHT');
console.log(' neu:', JSON.stringify(await probe('#sec-final .neu-card')));
console.log(' cc :', JSON.stringify(await probe('#sec-contact .contact-card')));
await b.close();
+11
View File
@@ -0,0 +1,11 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e = document.querySelector(s); const cs=getComputedStyle(e);
return { bgImg: cs.backgroundImage.slice(0,60) };
}, sel);
console.log('DARK neu bgImg:', (await probe('#sec-final .neu-card')).bgImg);
console.log('DARK cc bgImg:', (await probe('#sec-contact .contact-card')).bgImg);
await b.close();
+19
View File
@@ -0,0 +1,19 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
const probe = async (sel) => p.evaluate((s)=>{
const e=document.querySelector(s); const cs=getComputedStyle(e);
return { bg: cs.backgroundColor, bgImg: cs.backgroundImage.slice(0,45), shadowLen: cs.boxShadow.length,
dir: cs.flexDirection, pad: cs.paddingTop };
}, sel);
const neu = await probe('#sec-final .neu-card');
const cc = await probe('#sec-contact .contact-card');
const neuShadow = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-final .neu-card')).boxShadow);
const ccShadow = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-contact .contact-card')).boxShadow);
console.log('neu:', JSON.stringify(neu));
console.log('cc :', JSON.stringify(cc));
console.log('shadows equal:', neuShadow===ccShadow);
console.log('errs:', errs);
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const logs=[];
p.on('console', m=>logs.push('['+m.type()+'] '+m.text()));
p.on('pageerror', e=>logs.push('[pageerror] '+e.message));
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(async ()=>{
const mod = await import('/vendor/paper-shaders/index.js');
const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod;
const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative';
document.body.appendChild(host);
const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString);
const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 };
const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0);
return { keys: Object.keys(m.uniformLocations), uColorsLoc: m.uniformLocations.u_colors, uColorsCountLoc: m.uniformLocations.u_colorsCount };
});
console.log('RESULT', JSON.stringify(r));
console.log('LOGS:\n'+logs.join('\n'));
await b.close();
+32
View File
@@ -0,0 +1,32 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(async ()=>{
const mod = await import('/vendor/paper-shaders/index.js');
const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod;
const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative';
document.body.appendChild(host);
const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString);
const m = new ShaderMount(host, meshGradientFragmentShader, { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 }, { preserveDrawingBuffer:true, antialias:true }, 1.0);
const gl=m.gl, pr=m.program;
// правильное чтение: getUniform ВОЗВРАЩАЕТ значение
const gf=(n)=>gl.getUniform(pr, gl.getUniformLocation(pr,n));
const colorsWritten = gf('u_colors[0]'); // Float32Array(4)
const countWritten = gf('u_colorsCount'); // number
const swirlWritten = gf('u_swirl');
// readPixels скан
m.render(performance.now()); m.render(performance.now());
const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let bright=0,maxc=0; for(let i=0;i<buf.length;i+=4){const mx=Math.max(buf[i],buf[i+1],buf[i+2]); if(mx>120)bright++; maxc=Math.max(maxc,mx);}
return {
u_colors0: colorsWritten ? Array.from(colorsWritten).map(x=>+x.toFixed(2)) : null,
u_colorsCount: countWritten,
u_swirl: swirlWritten,
brightPixels: bright, maxChannel: maxc,
};
});
console.log(JSON.stringify(r,null,1));
await b.close();
+25
View File
@@ -0,0 +1,25 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(async ()=>{
const mod = await import('/vendor/paper-shaders/index.js');
const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod;
const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative';
document.body.appendChild(host);
const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString);
const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 };
console.log('uniforms passed:', JSON.stringify(uniforms.u_colors), 'count', uniforms.u_colorsCount);
const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0);
m.render(performance.now()); m.render(performance.now());
const gl=m.gl;
const cnt=new Float32Array(1); gl.getUniform(m.program, gl.getUniformLocation(m.program,'u_colorsCount'), cnt);
const c0=new Float32Array(4); gl.getUniform(m.program, gl.getUniformLocation(m.program,'u_colors[0]'), c0);
const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let bright=0,maxc=0; for(let i=0;i<buf.length;i+=4){const mx=Math.max(buf[i],buf[i+1],buf[i+2]); if(mx>120)bright++; maxc=Math.max(maxc,mx);}
return { count:cnt[0], c0:Array.from(c0).map(x=>+x.toFixed(2)), bright, maxc };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+24
View File
@@ -0,0 +1,24 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1280,height:1200});
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const demo = await p.$('[data-shader-demo]');
await demo.evaluate(el=>el.scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,400));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,700)); }
const el = await p.$('[data-shader-demo]');
const box = await el.boundingBox();
await el.screenshot({path:'/tmp/demo.png'});
const {data,info}=await sharp('/tmp/demo.png').raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height, rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=3){ rs+=data[i];gs+=data[i+1];bs+=data[i+2]; if(i%1500===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
return { key: key||'mesh', avg:[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], distinct: cols.size };
}
const r1=await probe(); const r2=await probe('grain'); const r3=await probe('voronoi'); const r4=await probe('staticMesh');
console.log(JSON.stringify({r1,r2,r3,r4,errs},null,1));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,900));
// координаты демо-карточки
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
await p.screenshot({path:'/tmp/full.png'});
const img = sharp('/tmp/full.png');
const {data,info}=await img.raw().toBuffer({resolveWithObject:true});
// вырежем центр демо-карточки (по координатам вьюпорта)
const x0=Math.floor(rect.x+rect.w*0.2), y0=Math.floor(rect.y+rect.h*0.2);
const x1=Math.floor(rect.x+rect.w*0.8), y1=Math.floor(rect.y+rect.h*0.8);
let rs=0,gs=0,bs=0,n=0; const cols=new Set();
for(let y=y0;y<y1;y+=3){ for(let x=x0;x<x1;x+=3){ const i=(y*info.width+x)*3; rs+=data[i];gs+=data[i+1];bs+=data[i+2]; cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); n++; } }
console.log('demo card region avg:', [Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], 'distinct coarse colors:', cols.size);
await b.close();
+34
View File
@@ -0,0 +1,34 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,600));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,900)); }
// снимок canvas напрямую через toDataURL -> png
const dataUrl = await p.evaluate(()=>{
const cv=document.querySelector('[data-shader-demo] canvas');
if(!cv) return null;
try { return cv.toDataURL('image/png').slice(0,30); } catch(e){ return 'ERR '+e.message; }
});
const buf = await p.evaluate(()=>{
const cv=document.querySelector('[data-shader-demo] canvas');
return cv ? cv.toDataURL('image/png') : null;
});
let distinct=0, avg=[0,0,0];
if(buf){ const b64=buf.split(',')[1]; const {data,info}=await sharp(Buffer.from(b64,'base64')).raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=4){ rs+=data[i];gs+=data[i+1];bs+=data[i+2];
if(i%2000===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
avg=[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]; distinct=cols.size; }
return { key:key||'mesh', dataUrlHead:dataUrl, avg, distinct };
}
console.log(JSON.stringify(await probe(),null,0));
console.log(JSON.stringify(await probe('grain'),null,0));
console.log(JSON.stringify(await probe('voronoi'),null,0));
console.log(JSON.stringify(await probe('staticMesh'),null,0));
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
// прокрутим ровно к карточке через window.scrollTo по абсолютному смещению
const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; });
await p.evaluate((t)=>window.scrollTo(0, t-200), top);
await new Promise(r=>setTimeout(r,900));
await p.screenshot({path:'/tmp/full2.png'});
const {data,info}=await sharp('/tmp/full2.png').raw().toBuffer({resolveWithObject:true});
const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; });
const x0=Math.max(0,Math.floor(rect.x+20)), y0=Math.max(0,Math.floor(rect.y+20));
const x1=Math.min(info.width,Math.floor(rect.x+rect.w-20)), y1=Math.min(info.height,Math.floor(rect.y+rect.h-20));
let rs=0,gs=0,bs=0,n=0; const cols=new Set();
if(x1>x0&&y1>y0){ for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){ const i=(y*info.width+x)*3; rs+=data[i];gs+=data[i+1];bs+=data[i+2]; cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); n++; } } }
console.log('region avg', n?[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]:'empty', 'distinct', cols.size, 'rect', rect, 'imgsize', info.width+'x'+info.height);
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,1000)); }
const el = await p.$('[data-shader-demo]');
await el.screenshot({path:'/tmp/demo.png'});
const {data,info}=await sharp('/tmp/demo.png').raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=3){ rs+=data[i];gs+=data[i+1];bs+=data[i+2]; if(i%2000===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
return { key:key||'mesh', avg:[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], distinct:cols.size };
}
console.log(JSON.stringify(await probe()));
console.log(JSON.stringify(await probe('grain')));
console.log(JSON.stringify(await probe('voronoi')));
console.log(JSON.stringify(await probe('staticMesh')));
await b.close();
+26
View File
@@ -0,0 +1,26 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,700));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,900)); }
const buf = await p.evaluate(()=>{ const cv=document.querySelector('[data-shader-demo] canvas'); return cv?cv.toDataURL('image/png'):null; });
let avg=[0,0,0], distinct=0;
if(buf){ const {data,info}=await sharp(Buffer.from(buf.split(',')[1],'base64')).raw().toBuffer({resolveWithObject:true});
let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set();
for(let i=0;i<data.length;i+=4){ rs+=data[i];gs+=data[i+1];bs+=data[i+2]; if(i%1500===0) cols.add(`${data[i]>>5},${data[i+1]>>5},${data[i+2]>>5}`); }
avg=[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]; distinct=cols.size; }
return { key:key||'mesh', avg, distinct, hasCanvas:!!buf };
}
console.log(JSON.stringify(await probe(),null,0));
console.log(JSON.stringify(await probe('grain'),null,0));
console.log(JSON.stringify(await probe('voronoi'),null,0));
console.log(JSON.stringify(await probe('staticMesh'),null,0));
console.log('errs:', errs);
await b.close();
+11
View File
@@ -0,0 +1,11 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300,deviceScaleFactor:1});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView());
await new Promise(r=>setTimeout(r,800));
await p.screenshot({path:'/tmp/sec-shader.png'});
console.log('saved');
await b.close();
+28
View File
@@ -0,0 +1,28 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1280,height:1200});
const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1000));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,500));
async function probe(key){
if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,800)); }
return await p.evaluate(()=>{
const el=document.querySelector('[data-shader-demo]');
const cv=el.querySelector('canvas');
if(!cv) return {canvas:false};
const gl=cv.getContext('webgl2')||cv.getContext('webgl');
const px=new Uint8Array(4);
// читаем угол (0,0) — там обычно цвет градиента
gl.readPixels(2,2,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { canvas:true, w:cv.width, h:cv.height, corner:Array.from(px), key: el.dataset.shaderKey };
});
}
console.log('mesh:', JSON.stringify(await probe()));
console.log('grain:', JSON.stringify(await probe('grain')));
console.log('voronoi:', JSON.stringify(await probe('voronoi')));
console.log('staticMesh:', JSON.stringify(await probe('staticMesh')));
console.log('errs:', errs);
await b.close();
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -e
HOST="root@155.212.186.39"
SSH_KEY="$HOME/.ssh/beget"
SRC_DIR="$(cd "$(dirname "$0")" && pwd)/public/"
echo "📦 Deploy mamadaevv.site..."
echo "Src: $SRC_DIR"
REMOTE_INFO=$(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$HOST" '
CONTAINER=$(docker ps --format "{{.Names}}" | grep "s9mrcps8xw02r1dak0fsznix" | head -1)
VOLUME_PATH=$(docker inspect "$CONTAINER" 2>/dev/null | python3 -c "
import sys,json
mounts=json.load(sys.stdin)[0].get(\"Mounts\",[])
print([m[\"Source\"] for m in mounts if m[\"Destination\"]==\"/usr/share/nginx/html\"][0])
")
echo "$CONTAINER|$VOLUME_PATH"
')
IFS='|' read -r CONTAINER VOLUME_PATH <<< "$REMOTE_INFO"
echo "Container: $CONTAINER"
echo "Volume: $VOLUME_PATH"
# Копируем файлы
cd "$SRC_DIR"
tar cf - . | ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$HOST" \
"tar xf - -C $VOLUME_PATH"
# Фикс MIME для .mjs (plyr.min.mjs модуль)
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$HOST" \
"docker exec $CONTAINER sh -c \"
if ! grep -q 'application/javascript mjs' /etc/nginx/mime.types 2>/dev/null; then
sed -i 's|application/wasm|application/javascript mjs;\\n application/wasm|' /etc/nginx/mime.types
nginx -s reload && echo 'MIME fix applied'
fi
\"" 2>/dev/null || true
echo ""
echo "✅ Done! https://mamadaevv.site"
+30
View File
@@ -0,0 +1,30 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('console',m=>{ if(m.type()==='error'||m.type()==='warning') errs.push(m.type()+': '+m.text()); });
p.on('pageerror',e=>errs.push('PAGEERR: '+e.message));
p.on('requestfailed',r=>errs.push('REQFAIL: '+r.url()+' '+r.failure()?.errorText));
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const diag = await p.evaluate(()=>{
const sec=document.querySelector('#sec-shader');
const demo=document.querySelector('[data-shader-demo]');
const canvas=document.querySelector('#sec-shader canvas');
const sw=document.querySelectorAll('[data-shader-switch]').length;
return {
secExists: !!sec,
secH: sec?Math.round(sec.getBoundingClientRect().height):0,
demoExists: !!demo,
demoH: demo?Math.round(demo.getBoundingClientRect().height):0,
canvasCount: document.querySelectorAll('#sec-shader canvas').length,
swCount: sw,
swVisible: sw?getComputedStyle(document.querySelector('[data-shader-switch]')).display:'n/a',
};
});
// проверим доступен ли модуль
const modOk = await p.evaluate(async()=>{
try { const m = await import('/vendor/paper-shaders/index.js'); return 'OK keys:'+Object.keys(m).length; }
catch(e){ return 'IMPORT ERR: '+e.message; }
});
console.log(JSON.stringify({diag, modOk, errs: errs.slice(0,10)}, null, 1));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
// два render (drawArrays инициализируется лениво на 1-м)
m.render(performance.now()); m.render(performance.now());
const W=m.canvasElement.width, H=m.canvasElement.height;
const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let bright=0, maxc=0;
for(let i=0;i<buf.length;i+=4){ const r=buf[i],g=buf[i+1],bl=buf[i+2]; const mx=Math.max(r,g,bl); if(mx>120) bright++; maxc=Math.max(maxc,mx); }
const cntLoc=gl.getUniformLocation(m.program,'u_colorsCount'); const cv=new Float32Array(1); gl.getUniform(m.program,cntLoc,cv);
const c0=new Float32Array(4); gl.getUniform(m.program,gl.getUniformLocation(m.program,'u_colors[0]'),c0);
return { brightPixels: bright, maxChannel: maxc, uColorsCountFloat: cv[0], uColor0: Array.from(c0).map(x=>+x.toFixed(2)) };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+51 -1
View File
@@ -141,6 +141,56 @@
**Сетка + отложенный `src`:** в ячейках можно задать реальный URL в **`data-src`** и плейсхолдер в `src` (крошечный `data:image/gif;base64,…`), чтобы [`initCaseSequentialImages`](public/js/modules/cases/sequential-images.js) подставлял картинки **по порядку в DOM** (в т.ч. строка за строкой в галерее). Пока у `img` есть `data-src`, просмотр по клику не открывается. Пример: [`src/pages/cases/lunacy.html`](src/pages/cases/lunacy.html).
### Слайдер изображений с точками (`case-media-slider`)
Одна картинка во весь блок, навигация стрелками «пред/след» и пагинация точками (активная — акцентным цветом). Переиспользует маски стрелок `shevron-left/right.svg` и токены темы. Логика: [`public/js/modules/cases/media-slider.js`](public/js/modules/cases/media-slider.js) (`initCaseMediaSlider()` вызывается из [`public/js/main.js`](public/js/main.js)).
**Возможности:**
- стрелки `data-slider-prev` / `data-slider-next` — переключают соседний слайд, блокируются (`disabled`) на краях;
- точки `data-slider-dot` (в `data-slider-dots`) — переход на конкретный слайд, активная получает `is-active` + `aria-selected`;
- клавиатура: ←/→ внутри блока, Home/End на точках;
- тач/мышь-свайп (только горизонтальный);
- ленивая подгрузка соседних кадров: у `<img>` можно задать `data-src` (временный `src` — прозрачный GIF), скрипт подставит при подходе, как у галереи; пока есть `data-src`, клик по viewer не открывается;
- клик по активному кадру открывает общий viewer кейса (`data-image-viewer`).
**Ширина слайдера подстраивается под ширину картинок** (высота — автоматически по контенту). Модификаторы как у статичных `.case-media`:
- базовый (без модификатора) / `.case-media--content` — ширина = `--slider-natural-w` (по умолчанию 100% колонки контента). Задайте на `<figure>` inline-стиль `--slider-natural-w: <ширина картинки>px`, чтобы слайдер точно встал в размер кадров и не превышал его; `max-width` ограничен вьюпортом, поэтому на узких экранах сжимается;
- `.case-media--wide` — слайдер выходит за колонку контента (как одиночные wide-картинки), центрируется по вьюпорту;
- `.case-media--natural` — как базовый, но `max-width` по умолчанию `600px` (переопределите `--slider-natural-w`).
Кнопки «пред/след» и точки находятся **внутри** `.case-media-slider__viewport`, поэтому не участвуют в расчёте ширины блока.
**Разметка** (число `data-slider-dot` должно равняться числу `.case-media-slider__slide`; `aria-label` точек — «Перейти к изображению N»):
```html
<figure class="case-media case-media-slider" data-case-media-slider>
<div class="case-media-slider__viewport" data-slider-viewport>
<div class="case-media-slider__track" data-slider-track>
<div class="case-media-slider__slide">
<img src="../assets/<папка>/<файл-1>.webp" alt="…" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="../assets/<папка>/<файл-2>.webp" alt="…" loading="lazy" decoding="async" data-image-viewer />
</div>
</div>
</div>
<button type="button" class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--right" aria-hidden="true"></span>
</button>
<div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 1"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 2"></button>
</div>
</figure>
```
Живой пример: [`src/pages/cases/template-elements-demo.html`](src/pages/cases/template-elements-demo.html) (секция «Слайдер»).
### Последовательная подгрузка (`data-case-sequential-images`)
- Оберни фрагмент статьи с картинками (часто весь `.case-article`): **`data-case-sequential-images`** на контейнере.
@@ -183,7 +233,7 @@ PNG/JPEG → WebP по папке: `docs/image-optimization.md`, скрипт `s
Подключаемые через `{{> head-links}}` сценарии уже инициализируют:
- последовательную подгрузку `data-src` внутри `[data-case-sequential-images]` (`initCaseSequentialImages`);
- просмотр картинок по клику (`data-image-viewer`, делегирование на документ);
- просмотр картинок по клику (`data-image-viewer`, делегирование на документ); при открытии из **галереи** (`.case-media-gallery`) или **слайдера** (`.case-media-slider`) в нижней панели просмотрщика появляются **кнопки-пилюли «пред/след»** и **точки-пагинация** — переключают только картинки этого блока (клавиши ←/→, свайп по горизонтали, клик по точке); у одиночной картинки кнопки и точки скрыты;
- плеер видео кейса (`data-case-video-player`);
- натуральный размер для `.case-media--natural img`;
- сворачивание длинного скрина (`[data-case-tall-media]`, `initCaseTallMediaExpand`).
-57
View File
@@ -1,57 +0,0 @@
# Синхронизация `public` -> `gitverse`
## Назначение
Скрипт `sync-gitverse` копирует содержимое `public` в папку `gitverse`, затем выполняет коммит и пуш в репозиторий внутри `gitverse`.
## Запуск
- Через npm:
- `npm run sync-gitverse`
- Напрямую:
- `node scripts/sync-gitverse.mjs`
- С явными путями:
- `node scripts/sync-gitverse.mjs <sourceDir> <targetDir>`
По умолчанию:
- `sourceDir = public`
- `targetDir = gitverse`
## Что делает скрипт
1. Проверяет, что исходная папка существует.
2. В целевой папке удаляет всё, кроме:
- `.git`
- `.gitverse`
3. Копирует содержимое `public` в `gitverse` (файлы `.png` пропускаются — в деплое используются WebP и остальные форматы).
4. В репозитории `gitverse` выполняет:
- `git add .`
- `git commit -m "sync public YYYY-MM-DD HH:mm"` (только если есть изменения)
- `git push`
5. После успешного пуша открывает в системном браузере по умолчанию: `https://mamadaevv.gitverse.site/me/`
Если изменений после копирования нет, коммит и пуш не делаются.
## Инструкция для агента (Cursor)
Когда пользователь просит **выложить на GitVerse**, **собрать/задеплоить gitverse**, **синхронизировать с gitverse** или аналогично:
1. **Сборка `public/`:** если менялись `src/pages/**` или `src/partials/**` — `npm run build:pages`; если шрифты/Plyr/`package.json` или `scripts/copy-vendor.mjs` — `npm run build` (сначала `vendor:copy`, затем страницы). См. [`docs/vendor-assets.md`](vendor-assets.md).
2. **Синк и пуш:** выполни `npm run sync-gitverse` из корня. Нужны разрешения **network** и **git_write** (SSH на GitVerse, пуш из папки `gitverse`).
3. **Ожидания:** скрипт не копирует файлы `.png` в `gitverse`. После **успешного** `git push` открывается системный браузер с `https://mamadaevv.gitverse.site/me/`. Если изменений в `gitverse` после копирования нет — коммит/пуш не выполняются, браузер не открывается.
4. **Не вызывай** `sync-gitverse` без запроса пользователя: скрипт очищает целевую папку (кроме `.git` и `.gitverse`) и пушит в remote.
5. **Корневой git репозиторий** этого проекта считается **только локальным**: не пушь из корня в `origin` и не настраивай remote без явной просьбы пользователя с URL. Внешняя выкладка — через `gitverse`, не через `git push` в корне.
6. Деплой на VPS — отдельный сценарий, см. **`docs/vps-deploy.md`**.
Подробности и ограничения — в разделах выше.
## Требования
- В `gitverse` уже должен быть инициализирован git-репозиторий (папка `.git`).
- Должен быть настроен доступ на push (SSH-ключ/права).
## Важно
- Скрипт удаляет в `gitverse` все файлы и папки, кроме `.git` и `.gitverse`.
- Не запускай скрипт, если в `gitverse` есть локальные несохраненные изменения, которые нужно оставить.
+5 -11
View File
@@ -1,10 +1,11 @@
# Локальный просмотр сайта
Сайт в режиме разработки обслуживается из папки `**public/**` через [BrowserSync](https://browsersync.io/): при изменении файлов страница в браузере обновляется сама.
Сайт в режиме разработки обслуживается из папки `**public/**` через нативный HTTP-сервер (Node.js `http` модуль): статика + API правок контента.
## Требования
- Установлены **Node.js** и **npm** (идут вместе с Node).
- **Node.js 22** (см. `.node-version` в корне проекта). fnm/fvm переключают автоматически.
- **npm** (идёт вместе с Node).
## Первый раз в проекте
@@ -23,18 +24,11 @@ npm run vendor:copy
npm run serve
```
Скрипт [`scripts/serve-dev.mjs`](../scripts/serve-dev.mjs): статика из `public/`, API правок контента, перезагрузка **только** при изменениях в `public/**` (запись в `texts/content-edits/` вкладку не перезагружает).
Без API: `npm run serve:static`.
Скрипт [`scripts/serve-dev.mjs`](../scripts/serve-dev.mjs): статика из `public/`, API правок контента. Нет авто-релиоада — после правок в `public/` обновляй страницу вручную.
## Куда смотреть
- **Сайт:** [http://localhost:4175](http://localhost:4175) — откроется `public/index.html` и остальные пути относительно `public/`.
- **Панель BrowserSync UI:** [http://localhost:4176](http://localhost:4176) (опционально, для синхронизации и настроек).
## Что отслеживается
Любые изменения в `**public/**`**: HTML, CSS, JS, картинки. Папка `texts/content-edits/` **не** в watch — autosave правок не сбрасывает режим редактирования.
## API правок контента
@@ -48,4 +42,4 @@ npm run serve
## Остановка
В терминале: **Ctrl+C**.
В терминале: **Ctrl+C**.
+51
View File
@@ -0,0 +1,51 @@
# PDF резюме
## Генерация PDF
Скрипт `scripts/generate-resume-pdf.mjs` открывает `resume/` через Puppeteer, применяет шрифт geologica (PT Sans), убирает подложку/обводку блока `.resume-doc` и сохраняет PDF.
**Важно**: скрипт требует запущенный dev-сервер (`npm run serve` → `localhost:4175`).
### Запуск
```bash
npm run generate:resume-pdf
```
или вручную:
```bash
node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica
```
Параметры:
- `[url]` — URL страницы (по умолчанию `http://localhost:4175/resume/`)
- `[font]` — ID шрифта: `geologica`, `merriweather`, `mono` (по умолчанию `geologica`)
### Выходной файл
`public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf`
Кнопка «Скачать в PDF» на странице `resume/` — это `<a>` ссылка на этот файл.
### Когда перегенерировать
- После правок в `src/pages/resume.html` (контент, стили)
- При смене шрифта по умолчанию в скрипте
### Что скрипт делает с страницей
1. Устанавливает шрифт через `localStorage` + `data-font`
2. Ждёт загрузки шрифтов (`document.fonts.ready`)
3. Удаляет `header`, `footer`, `nav`, `.fab-stack`, `.cookie-notice`, `.skip-link`
4. Убирает `max-width` с `.resume-doc`, ставит `width: 100%`
5. Убирает фон (`background: transparent`) и обводку (`border: none`)
6. Захватывает `.resume-doc` как A4 PDF с `printBackground: true`
### Не попадает в PDF
Из `.resume-doc` удаляются (но остаются на сайте):
- Фон блока
- Обводка блока
Всё остальное (отступы, шрифты, сетка) — как на сайте.
+104
View File
@@ -0,0 +1,104 @@
# Локализация и перевод
Сайт локализован **build-time**: для каждого языка собирается отдельная папка/URL (`/` — русский, `/en/` — английский). Клиентского переключателя нет — это сделано ради SEO, корректной генерации PDF (Puppeteer) и совместимости с content-edit-mode.
## Архитектура
- `LOCALES = ["ru", "en"]`, `DEFAULT_LOCALE = "ru"` — задаётся в `scripts/build-pages.mjs`.
- Дефолтная локаль собирается в корень `public/`, остальные — в `public/<lang>/` (`public/en/`).
- Переключатель языка: `src/partials/lang-switch.html` (показывает RU/EN, `aria-current` на активном).
## Как работает сборка (`build-pages.mjs`)
1. **UI-строки** (навигация, футер, кнопки) вынесены в словари `src/i18n/{ru,en}.json` и вставляются токеном `{{i18n:key}}`. Для не-дефолтной локали значение берётся из `en.json`, при отсутствии ключа — фоллбэк на `ru.json`. Неразрезолвенный токен остаётся как `{{i18n:key}}` (признак пропущенного ключа).
2. **Тело страницы** (главная, cookie-policy, кейсы) живёт в per-locale исходниках:
- `src/pages/<page>.html` — русский (дефолт).
- `src/pages/<page>.en.html` — английский override. Если файла нет, берётся русский.
- В сборке базовый walk **исключает** `*.en.html`, чтобы они не собирались как отдельные страницы.
3. **Partials**: для не-дефолтной локали инклуд `{{> name}}` сначала ищет `src/partials/name.en.html`, иначе `name.html`. Так переводятся частично захардкоженные partials (например, `projects-section.en.html`).
4. **`assetPrefix`**: относительный префикс к ассетам. Для `en` к значению из `baseVars` добавляется один уровень `../`, потому что страницы лежат в подпапке `public/en/`. Используй только для путей к ассетам (css/js/изображения).
5. **`pagePrefix`**: префикс до **корня локали** (`/en/` для en), без en-сдвига. Используй его для ВСЕХ навигационных ссылок (лого, «назад», футер, якоря `#section`, ссылки на другие страницы), чтобы они вели в ту же локаль, а не в корень сайта. `assetPrefix` предназначен только для путей к ассетам — для навигации он «перепрыгивает» через `/en/` и ведёт на ru, поэтому в навигационных `href` его НЕ применяй.
6. **Статик-ассеты** (css/js/vendor/favicon) в partials (`head-links.html`, `head-scripts.html`, `theme-init-script.html`, `head-plyr.html`, `page-scripts.html`) прописаны **root-absolute** (`/css/...`, `/js/...`, `/vendor/...`, `/assets/shared/...`). Причина: dev-сервер (`serve-dev.mjs`) отдаёт `index.html` (200) для отсутствующих файлов, маскируя 404, поэтому относительные `{{assetPrefix}}css` на `/en/` ломались бы. Держи статик-ссылки root-absolute.
7. `<html lang="ru">` подменяется на текущую локаль (`<html lang="en">`).
> Видимый контент страниц переводится через отдельные `*.en.html` исходники, а НЕ тысячами `{{i18n:}}`-токенов — так проще править большие тексты.
## Перевод через apinex
Бесплатный инструмент перевода: `POST https://api.apinex.bond/v1/tools/translator` (дока: `https://apinex.bond/developers/tools/translator`).
- **Ключ** — в `/root/.hermes/.env`, переменная `HERMES_CUSTOM_APINEX_API_KEY` (или `APINEX_API_KEY`). **Никогда не выводи значение ключа в чат/логи.**
- Заголовок: `Authorization: Bearer $KEY`.
- Тело: `{ "text": "строка|массив", "target_lang": "EN", "source_lang": "RU", "tag_handling": "html", "preserve_formatting": true }`. Батч до 50 строк / 100k символов, лимит 60 запросов/мин.
### Скрипт `scripts/translate.mjs`
Обёртка над API (Node, без зависимостей). Автоматически **защищает** от перевода шаблонные токены `{{assetPrefix}}`, `{{> partial}}`, `{{i18n:…}}` и содержимое `<code>` (через HTML-комментарии, восстанавливаются после). HTML-режим авто-включается, если во входе есть `<…>`.
```bash
cd /root/.hermes/projects/site-portfolio
# ключ в окружение (без печати значения)
export HERMES_CUSTOM_APINEX_API_KEY="$(grep -iE 'HERMES_CUSTOM_APINEX_API_KEY' /root/.hermes/.env | head -1 | cut -d= -f2- | sed 's/^["'\''"]//;s/["'\''"]$//')"
# список поддерживаемых языков
node scripts/translate.mjs --list-languages
# одна строка
node scripts/translate.mjs --target EN --source RU --text "Политика cookie"
# HTML из stdin (токены и <code> защищены)
echo '<p>{{assetPrefix}} <code>siteThemeV1</code></p>' | node scripts/translate.mjs --target EN --html
# файл страницы -> per-locale override
node scripts/translate.mjs --target EN --source RU --file src/pages/foo.html --out src/pages/foo.en.html
# пакет: одна строка на line
printf 'строка1\nстрока2\n' | node scripts/translate.mjs --target EN --batch
# npm-алиас
npm run translate -- --target EN --text "..."
```
- `--target` обязателен; `--source` опционален (авто-детект).
- `--html`/`--no-html` — принудительно вкл/выкл HTML-режим (иначе авто).
- `--out` пишет в файл, иначе вывод в stdout.
### Скилл `translate-apinex`
Загружается агентом при запросе перевода: содержит API, где ключ, защиту токенов и воркфлоу. Используй его вместо ручного копирования curl.
## Воркфлоу: перевести страницу
1. Создать `src/pages/<page>.en.html` (копия `<page>.html`).
2. Прогнать через скрипт:
```bash
node scripts/translate.mjs --target EN --source RU --file src/pages/<page>.en.html --out src/pages/<page>.en.html
```
Скрипт переведёт видимый текст, оставив `{{…}}` и `<code>` нетронутыми.
3. Если страница инклудит partials с захардкоженным русским (`projects-section`, `contact-section`, `cookie-notice`, `fab-stack`, `mini-projects-section`), создать их `.en.html` и перевести так же — сборка подхватит `.en`-вариант для `en`.
4. UI-строки, оставшиеся как `{{i18n:key}}`, добавить в `src/i18n/en.json`.
5. `npm run build:pages`.
6. Проверить:
```bash
curl -s http://localhost:4175/en/<page>/ | grep -o '<html lang="en">'
curl -s http://localhost:4175/en/<page>/ | grep -cE 'остался русский текст' # ожидаем 0
```
## Добавить новый язык
1. Добавить код в массив `LOCALES` в `scripts/build-pages.mjs`.
2. Создать `src/i18n/<code>.json` (UI-строки).
3. Создавать `<page>.<code>.html` / `<name>.<code>.html` override для нужных страниц/партилов (или полагаться на фоллбэк ru).
4. Пересобрать. URL новой локали — `/<code>/`.
## Где что лежит
| Файл | Назначение |
|---|---|
| `scripts/build-pages.mjs` | Сборка локалей, резолв `{{i18n:}}`, override страниц/партилов, `assetPrefix`, `lang` |
| `src/i18n/ru.json`, `src/i18n/en.json` | Словари UI-строк |
| `src/pages/<page>.en.html` | Переведённое тело страницы (override) |
| `src/partials/<name>.en.html` | Переведённый partial (override) |
| `src/partials/lang-switch.html` | Переключатель RU/EN |
| `scripts/translate.mjs` | CLI-обёртка над apinex translator |
| `.env` (`/root/.hermes/.env`) | `HERMES_CUSTOM_APINEX_API_KEY` — ключ apinex (не коммитить) |
+1 -5
View File
@@ -1,6 +1,6 @@
# Локальные vendor-ассеты (шрифты и Plyr)
Сайт не использует Google Fonts и jsDelivr в рантайме. Файлы лежат в `public/` и попадают на хостинг через `sync-gitverse`.
Сайт не использует Google Fonts и jsDelivr в рантайме. Файлы лежат в `public/` и попадают на хостинг через git push.
## Что где
@@ -49,7 +49,3 @@ npm run build:pages
1. Обновить версию в `package.json` (`dependencies.plyr`).
2. `npm install` → `npm run vendor:copy`.
3. При смене API — правки в `video-player.js`.
## Деплой
Перед `npm run sync-gitverse` выполни `npm run build`, чтобы в `public/` были актуальные шрифты, Plyr и HTML.
-59
View File
@@ -1,59 +0,0 @@
# Деплой на VPS (инкрементальный)
Скрипт `scripts/deploy-vps.mjs` загружает содержимое `public/` на VPS, передавая только изменённые файлы.
## Принцип работы
1. **Манифест** (`.deploy-manifest.json`) хранит `mtime` и `size` каждого файла с прошлого деплоя.
2. При запуске скрипт сканирует `public/`, сравнивает с манифестом:
- Файлы с изменившимся `mtime` или `size` — загружаются.
- Файлы, которых нет локально, но есть в манифесте — удаляются с сервера.
3. Изменённые файлы пакуются в `tar` и передаются **одним SSH-соединением** (без сжатия, чтобы не тратить CPU на уже сжатые медиа).
4. Манифест обновляется после успешного деплоя.
При первом запуске (манифест пуст) заливаются все файлы — ~3 минуты на ~400 файлов. Повторный деплой (1–10 изменившихся файлов) — 2–5 секунд.
## Запуск
```bash
npm run deploy:vps
# или напрямую
node scripts/deploy-vps.mjs [sourceDir]
```
По умолчанию `sourceDir = public`.
## Рецепты
### Только в Coolify
Запрос пользователя: «задеплой в Coolify», «задеплой на VPS».
```bash
npm run deploy:vps
```
Нужны разрешения: **all** (SSH-ключ `~/.ssh/beget`, доступ к файлам `public/`).
### Везде (VPS + GitVerse)
```bash
npm run deploy
```
Выполняет `deploy:vps`, затем `sync-gitverse`.
## Конфигурация
- **SSH-ключ:** `~/.ssh/beget`
- **Хост:** `root@155.212.186.39`
- **Целевая папка:** `/data/static-site`
- **Манифест:** `.deploy-manifest.json` в корне проекта (не коммитится)
## Сброс манифеста
Если нужно залить всё с нуля — удалить `.deploy-manifest.json`:
```bash
rm .deploy-manifest.json
```
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[];
p.on('pageerror', e=>errs.push('PE '+e.message));
p.on('console', m=>{ if(m.type()==='error') errs.push('CE '+m.text()); });
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount;
return { hasMount: !!m, speed: m?.currentSpeed, isVp: m?.isInViewport };
});
console.log('MOUNT', JSON.stringify(r));
console.log('ERRS', errs.slice(0,5));
await b.close();
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[];
p.on('pageerror', e=>errs.push('PE '+e.message));
p.on('console', m=>{ if(m.type()==='error') errs.push('CE '+m.text()); });
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1000));
const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{speed:m.currentSpeed,frame:m.getCurrentFrame(),inVp:m.isInViewport}:null; });
await new Promise(r=>setTimeout(r,2000));
const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{speed:m.currentSpeed,frame:m.getCurrentFrame(),inVp:m.isInViewport}:null; });
console.log('A',JSON.stringify(a),'C',JSON.stringify(c));
console.log('ANIMATING:', c && a && c.frame>a.frame && c.speed>0);
console.log('ERRS', errs.slice(0,5));
await b.close();
+12
View File
@@ -0,0 +1,12 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800)); // просто ждём, НЕ скроллим к секции
const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{frame:m.getCurrentFrame(),speed:m.currentSpeed,inVp:m.isInViewport}:null; });
await new Promise(r=>setTimeout(r,2000));
const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{frame:m.getCurrentFrame(),speed:m.currentSpeed,inVp:m.isInViewport}:null; });
console.log('A',JSON.stringify(a)); console.log('C',JSON.stringify(c));
console.log('ANIMATING:', !!c && c.frame>a.frame && c.speed>0);
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,500));
// достанем mount через глобальный хук: ShaderMount сохраняет себя в parentElement.paperShaderMount
const r = await p.evaluate(async()=>{
const wrap=document.querySelector('[data-shader-demo] .card-shader');
const m=wrap.paperShaderMount;
if(!m) return {hasMount:false};
const f1=m.getCurrentFrame();
await new Promise(r=>setTimeout(r,600));
const f2=m.getCurrentFrame();
return { hasMount:true, frame1:f1, frame2:f2, rafRunning: f2>f1, speed: m.speed, inViewport: m.isInViewport, currentSpeed: m.currentSpeed };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+19
View File
@@ -0,0 +1,19 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,800));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
const errs=[];
// проверим статус программы и шейдеров
const vs = gl.getProgramParameter(m.program, gl.LINK_STATUS);
// проверим, залит ли canvas хоть чем-то: clearColor потом readPixels
gl.clearColor(1,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
const px=new Uint8Array(4); gl.readPixels(10,10,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { linkStatus: vs, afterClearRed: Array.from(px), glError: gl.getError() };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+19
View File
@@ -0,0 +1,19 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
gl.getError(); // сброс
m.render(performance.now());
const e = gl.getError();
const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
// проверим a_position location
const aLoc = gl.getAttribLocation(m.program, 'a_position');
return { glErrorAfterRender: e, centerPx: Array.from(px), aPosLoc: aLoc, clearC: (()=>{const c=new Float32Array(4); gl.getClearColor?.(c); return Array.from(c);})() };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+24
View File
@@ -0,0 +1,24 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
const flat=[0.486,0.361,1,1, 0,0.761,0.78,1, 1,0.361,0.541,1, 1,0.702,0.278,1];
gl.useProgram(m.program);
gl.uniform4fv(gl.getUniformLocation(m.program,'u_colors'), new Float32Array(flat));
gl.uniform1f(gl.getUniformLocation(m.program,'u_colorsCount'),4);
gl.drawArrays(gl.TRIANGLES,0,6);
const W=m.canvasElement.width,H=m.canvasElement.height;
const pts=[[100,100],[360,100],[620,100],[100,220],[360,220],[620,220],[100,340],[360,340],[620,340]];
const out=pts.map(([x,y])=>{const px=new Uint8Array(4);gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);return [x,y,Array.from(px)];});
// макс яркость по всему canvas
const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf);
let maxc=0; for(let i=0;i<buf.length;i++) maxc=Math.max(maxc,buf[i]);
return { samples:out, maxChannel:maxc };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+17
View File
@@ -0,0 +1,17 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const keys=['u_time','u_colors','u_colorsCount','u_distortion','u_swirl','u_grainMixer','u_grainOverlay'];
const out={};
keys.forEach(k=>{ const l=m.uniformLocations[k]; out[k]= l===null?'NULL':(l===undefined?'UNDEF':typeof l); });
// также посмотрим this.providedUniforms
const pu = m.providedUniforms ? Object.keys(m.providedUniforms) : 'none';
return { locations: out, providedKeys: pu };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount;
const gl=m.gl;
const loc0 = gl.getUniformLocation(m.program, 'u_colors');
const locB = gl.getUniformLocation(m.program, 'u_colors[0]');
// вручную зададим 4 цвета vec4
const flat = [0.486,0.361,1,1, 0,0.761,0.78,1, 1,0.361,0.541,1, 1,0.702,0.278,1];
gl.useProgram(m.program);
if (loc0) gl.uniform4fv(loc0, flat); else if (locB) gl.uniform4fv(locB, flat);
gl.drawArrays(gl.TRIANGLES,0,6);
const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);
return { loc0null: loc0===null, locBnull: locB===null, centerAfterManual: Array.from(px) };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.setViewport({width:1100,height:1400});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-shader-grid')?.scrollIntoView());
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const cards=[...document.querySelectorAll('#sec-shader-grid .neu-card--shader')];
const info=cards.map(c=>{
const sh=c.querySelector('.card-shader');
const m=sh?.paperShaderMount;
const cs=getComputedStyle(c);
const beforeZ=getComputedStyle(c,'::before').zIndex;
return { hasShaderWrap:!!sh, mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null, bg:cs.backgroundColor, beforeZ };
});
return { count: cards.length, info };
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,5));
await b.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView());
await new Promise(r=>setTimeout(r,1200));
const r = await p.evaluate(()=>{
const cards=[...document.querySelectorAll('.card-shader[data-shader]')];
const info=cards.map(c=>({brand:c.dataset.shader, mounted:!!c.paperShaderMount, frag:c.paperShaderMount?.fragmentShader?.name||null}));
const f0=cards.find(c=>c.dataset.shader==='demo')?.paperShaderMount;
return { count: cards.length, info, demoFrame: f0?Math.round(f0.getCurrentFrame()):null,
demoAnimated: f0? (f0.getCurrentFrame()*0+ (f0.currentSpeed>0)) : null };
});
console.log(JSON.stringify(r,null,1));
console.log('ERRS', errs.slice(0,6));
await b.close();
+12
View File
@@ -0,0 +1,12 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[]; p.on('pageerror',e=>errs.push('PE: '+e.message)); p.on('console',m=>{if(m.type()==='error')errs.push('CON: '+m.text());});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const r = await p.evaluate(()=>{
const wrap=document.querySelector('[data-shader-demo] .card-shader');
return { hasMount: !!wrap?.paperShaderMount, scriptLoaded: !!window.__mountLabShaders };
});
console.log(JSON.stringify({r, errs}, null, 1));
await b.close();
+193 -1591
View File
File diff suppressed because it is too large. Load diff
+3 -5
View File
@@ -6,12 +6,10 @@
"build:pages": "node scripts/build-pages.mjs",
"build": "npm run vendor:copy && npm run build:pages",
"serve": "node scripts/serve-dev.mjs",
"serve:static": "browser-sync start --server \"public\" --files \"public/**/*\" --no-notify --no-open --no-ghost-mode --port 4175 --ui-port 4176",
"translate": "node scripts/translate.mjs",
"convert-videos": "node scripts/convert-videos.mjs",
"optimize-images": "node scripts/optimize-images.mjs",
"deploy:vps": "node scripts/deploy-vps.mjs",
"deploy": "npm run deploy:vps && npm run sync-gitverse",
"sync-gitverse": "node scripts/sync-gitverse.mjs"
"generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica"
},
"devDependencies": {
"@fontsource-variable/geologica": "^5.2.8",
@@ -20,7 +18,7 @@
"@fontsource/pt-mono": "^5.2.8",
"@fontsource/pt-sans": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"browser-sync": "^3.0.4",
"puppeteer": "^25.7.0",
"sharp": "^0.34.0"
},
"dependencies": {
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000});
await new Promise(r=>setTimeout(r,1200));
await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'}));
await new Promise(r=>setTimeout(r,500));
const r = await p.evaluate(()=>{
const wrap=document.querySelector('[data-shader-demo] .card-shader');
const m=wrap.paperShaderMount; const cv=wrap.querySelector('canvas');
const gl=cv.getContext('webgl2',{preserveDrawingBuffer:true})||cv.getContext('webgl',{preserveDrawingBuffer:true});
const read=()=>{ const a=new Uint8Array(4); gl.readPixels(cv.width/2|0,cv.height/2|0,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a); return Array.from(a); };
m.render(0); const t0=read();
m.render(3000); const t1=read();
m.render(8000); const t2=read();
return { t0, t1, t2 };
});
console.log(JSON.stringify(r,null,1));
await b.close();
+26
View File
@@ -0,0 +1,26 @@
import puppeteer from 'puppeteer';
const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] });
const p = await b.newPage();
const errs=[],warns=[];
p.on('pageerror', e=>errs.push('PE '+e.message));
p.on('requestfailed', r=>errs.push('REQFAIL '+r.url()+' '+r.failure()?.errorText));
p.on('console', m=>{ const t=m.type(); if(t==='error') errs.push('CE '+m.text()); if(t==='warning') warns.push('W '+m.text()); });
await p.setViewport({width:1100,height:1300});
await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'networkidle2',timeout:60000});
await new Promise(r=>setTimeout(r,1500));
const info = await p.evaluate(()=>{
const cs=document.querySelector('[data-shader-demo] .card-shader');
const m=cs?.paperShaderMount;
const bg=getComputedStyle(cs).backgroundColor;
const canvas=cs?.querySelector('canvas');
return {
hasWrap: !!cs, hasMount: !!m,
wrapBg: bg, hasCanvas: !!canvas,
canvasW: canvas?.width, canvasH: canvas?.height,
speed: m?.currentSpeed, frame: m?Math.round(m.getCurrentFrame()):null,
};
});
console.log('INFO', JSON.stringify(info,null,1));
console.log('ERRS', errs.slice(0,8));
console.log('WARNS', warns.slice(0,8));
await b.close();
+52
View File
@@ -0,0 +1,52 @@
const puppeteer = require('puppeteer');
async function probePage(browser, url, width) {
const page = await browser.newPage();
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'networkidle0' });
await page.waitForSelector('[data-mini-projects-list] .mini-projects-grid__item', { timeout: 15000 }).catch(()=>{});
await new Promise(r => setTimeout(r, 700));
const data = await page.evaluate(() => {
const list = document.querySelector('[data-mini-projects-list]');
if (!list) return { none: true };
const inner = list.closest('.section__inner');
const items = [...list.querySelectorAll('.mini-projects-grid__item')].filter(i => !i.hidden);
const rowCells = (() => {
const w = list.clientWidth;
if (w >= 1180) return 6;
if (w >= 880) return 4;
if (w >= 520) return 2;
return 1;
})();
return {
listWidth: list.clientWidth,
innerWidth: inner ? inner.clientWidth : null,
rowCells,
itemCount: items.length,
items: items.map(i => ({
cells: i.getAttribute('data-mp-cells'),
styleW: i.style.width,
x: i.style.transform,
})),
};
});
await page.close();
return data;
}
(async () => {
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox','--disable-setuid-sandbox'] });
const widths = [600, 760, 900, 1024, 1180, 1278, 1356, 1600];
const caseUrl = 'http://localhost:4175/cases/ai-platform-primo-rpa/';
const homeUrl = 'http://localhost:4175/';
for (const w of widths) {
const c = await probePage(browser, caseUrl, w);
console.log(`CASE vw=${w} inner=${c.innerWidth} rowCells=${c.rowCells} items=${c.itemCount} ` + JSON.stringify(c.items));
}
console.log('---');
for (const w of widths) {
const h = await probePage(browser, homeUrl, w);
console.log(`HOME vw=${w} inner=${h.innerWidth} rowCells=${h.rowCells} items=${h.itemCount} ` + JSON.stringify(h.items));
}
await browser.close();
})().catch(e => { console.error(e); process.exit(1); });
-13
View File
@@ -1,13 +0,0 @@
{
"model": "black-forest-labs/flux.2-klein-4b",
"messages": [
{
"role": "user",
"content": "Минималистичный AI-промпт для портретов в стиле high-fashion. Мягкий студийный свет, чистый фон и акцент на естественной красоте. Такой визуал идеально подходит для тренировки моделей освещения и теста фотореализма. Подходит для: fashion-портретов, нейрофотосессий, коммерческих стоков. Стиль: минимализм, чистая палитра, editorial precision. 👉 Узнай больше AI-идей и вдохновляющих промптов в моём телеграм-канале «Нейропольза» — переходи по ссылке. Minimalist AI portrait prompt with diffused studio light and fashion-magazine aesthetics. Perfect for clean editorial visuals and AI-photo training."
}
],
"modalities": ["image"],
"image_config": { "aspect_ratio": "9:16" },
"_provider": "PLZ",
"_cost": 1
}
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>bake paper texture</title></head>
<body style="margin:0">
<div id="host" style="width:512px;height:512px"></div>
<script type="module">
import { ShaderMount, paperTextureFragmentShader, getShaderNoiseTexture } from "/_lab/paper-bundle.js";
// Параметры из пресета Figma (по умолчанию)
const P = window.__PARAMS || {
u_colorBack: [1, 1, 1, 1],
u_colorFront: [0, 0, 0, 1],
u_contrast: 0.3,
u_roughness: 0.4,
u_fiber: 0.3,
u_fiberSize: 0.2,
u_crumples: 0.3,
u_crumpleSize: 0.35,
u_folds: 0.65,
u_foldCount: 5,
u_drops: 0.2,
u_fade: 0,
u_seed: 5.8,
u_scale: 0.6,
};
try {
const host = document.getElementById("host");
const noise = getShaderNoiseTexture();
await noise.decode();
const mount = new ShaderMount(
host,
paperTextureFragmentShader,
{ ...P, u_noiseTexture: noise },
// preserveDrawingBuffer обязателен: без него буфер очищается
// после композитинга и readPixels вернёт пустоту
{ antialias: false, preserveDrawingBuffer: true },
0,
0,
1,
);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = host.querySelector("canvas");
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
const w = canvas.width, h = canvas.height;
const px = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
let min = 255, max = 0, sum = 0, n = 0;
for (let i = 0; i < px.length; i += 4) {
const v = px[i];
if (v < min) min = v;
if (v > max) max = v;
sum += v; n++;
}
const avg = sum / n;
let vs = 0;
for (let i = 0; i < px.length; i += 4) vs += (px[i] - avg) ** 2;
// readPixels читает снизу вверх — переворачиваем
const out = document.createElement("canvas");
out.width = w; out.height = h;
const ctx = out.getContext("2d");
const img = ctx.createImageData(w, h);
for (let y = 0; y < h; y++) {
img.data.set(px.subarray((h - 1 - y) * w * 4, (h - y) * w * 4), y * w * 4);
}
ctx.putImageData(img, 0, 0);
window.__STATS = { w, h, min, max, avg: +avg.toFixed(2), sd: +Math.sqrt(vs / n).toFixed(2) };
window.__URL = out.toDataURL("image/png");
window.__DONE = true;
} catch (e) {
window.__ERR = e.message + " | " + e.stack;
}
</script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="geologica">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Песочница _lab</title>
<link rel="stylesheet" href="/css/main.css" />
<link rel="stylesheet" href="/_lab/lab.css" />
<style>
.lab-index {
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 6vw, 4rem) var(--space) 6rem;
}
.lab-index__h1 {
font-family: var(--font-accent);
font-size: clamp(1.8rem, 4vw, 2.5rem);
margin: 0 0 0.5rem;
}
.lab-index__sub {
color: var(--text-muted);
line-height: 1.6;
margin: 0 0 2.5rem;
max-width: 40rem;
}
.lab-index__list {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.lab-index__card {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--radius-md);
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
text-decoration: none;
box-shadow: 2px 2px 4px hsla(0, 0%, 0%, 0.1), 4px 8px 8px hsla(0, 0%, 0%, 0.15);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.lab-index__card:hover {
transform: translateY(-2px);
box-shadow: 2px 2px 4px hsla(0, 0%, 0%, 0.1), 4px 8px 12px hsla(0, 0%, 0%, 0.22);
}
.lab-index__title {
font-family: var(--font-accent);
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 0.35rem;
}
.lab-index__desc {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
}
.lab-index__note {
margin-top: 2.5rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid var(--line);
font-size: 0.85rem;
line-height: 1.6;
color: var(--text-muted);
}
.lab-index__note code {
font-family: var(--font);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
</style>
</head>
<body>
<div class="page-mesh-bg" aria-hidden="true"></div>
<main class="lab-index">
<h1 class="lab-index__h1">Песочница _lab</h1>
<p class="lab-index__sub">
Стенды для настройки видa элементов без сборки сайта. Правишь
<code>public/css/main.css</code> или файл стенда — страница перезагружается сама (live-reload
сервера <code>npm run serve</code>). Когда вид готов, переносишь компонент в
<code>src/pages/**</code> и собираешь <code>npm run build:pages</code>.
</p>
<div class="lab-index__list">
<a class="lab-index__card" href="/_lab/neu-card.html">
<div class="lab-index__title">neu-card</div>
<div class="lab-index__desc">
Неуморфная карточка: токены, состояния, текстура, вложенность.
</div>
</a>
<a class="lab-index__card" href="/_lab/template.html">
<div class="lab-index__title">template</div>
<div class="lab-index__desc">Пустой шаблон для нового стенда.</div>
</a>
</div>
<div class="lab-index__note">
<strong>Как добавить стенд:</strong> скопируй <code>/_lab/template.html</code> в
<code>/_lab/&lt;имя&gt;.html</code>, поправь заголовок и секции. Стили компонента — в
<code>public/css/main.css</code>, обвязка стенда — в <code>/_lab/lab.css</code>.
Переключатель темы подключается автоматически через <code>lab.js</code>.
</div>
</main>
</body>
</html>
+404
View File
@@ -0,0 +1,404 @@
/* Обвязка стенда. Сюда не пишем стили компонента — только рамки, сетки,
подписи и подложки. Стили neu-card живут в public/css/main.css. */
.lab {
max-width: 62rem;
margin: 0 auto;
padding: clamp(2rem, 5vw, 3.5rem) var(--space) 8rem;
}
.lab__head {
margin-bottom: clamp(2.5rem, 6vw, 4rem);
}
.lab__h1 {
margin: 0 0 0.5rem;
font-family: var(--font-accent);
font-size: clamp(1.8rem, 4vw, 2.5rem);
font-weight: 700;
line-height: 1.1;
}
.lab__sub {
margin: 0 0 1.25rem;
max-width: 42rem;
color: var(--text-muted);
line-height: 1.6;
}
.lab__nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.lab__nav a {
color: var(--accent-text);
text-decoration: none;
border-bottom: 1px solid transparent;
}
.lab__nav a:hover {
border-bottom-color: currentColor;
}
.lab__sec {
padding-top: clamp(2rem, 5vw, 3rem);
margin-bottom: clamp(2rem, 5vw, 3rem);
scroll-margin-top: 1.5rem;
}
.lab__h2 {
margin: 0 0 0.5rem;
font-family: var(--font-accent);
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
font-weight: 700;
line-height: 1.2;
}
.lab__h3 {
margin: 2.5rem 0 0.5rem;
font-family: var(--font-accent);
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
color: var(--text);
}
.lab__note {
margin: 0 0 1.5rem;
max-width: 46rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-muted);
}
.lab__caption {
margin: 0.75rem 0 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--text-muted);
}
.lab code {
font-family: var(--font);
font-size: 0.92em;
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.lab__row {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-start;
}
.lab__row--wrap .neu-card {
flex: 0 0 auto;
}
/* ── Токены ─────────────────────────────────────────────────────── */
.lab__tokens {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
}
.lab__token {
margin: 0;
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
background: var(--bg-elevated);
border: 1px solid var(--line);
}
.lab__token dt {
font-size: 0.82rem;
margin-bottom: 0.3rem;
}
.lab__token dt span {
color: var(--text-muted);
margin-left: 0.35rem;
}
.lab__token dd {
margin: 0;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-muted);
}
/* ── Фоны ───────────────────────────────────────────────────────── */
.lab__bg-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
}
.lab__bg-cell {
padding: 1.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--line);
}
.lab__bg-cell[data-bg="page"] {
background: var(--bg);
}
.lab__bg-cell[data-bg="elevated"] {
background: var(--bg-elevated);
}
.lab__bg-cell[data-bg="surface"] {
background: var(--surface);
}
.lab__bg-cell[data-bg="accent"] {
background: var(--accent);
color: var(--on-accent, var(--text));
}
.lab__bg-cell[data-bg="accent"] .lab__caption {
color: color-mix(in srgb, var(--on-accent, var(--text)) 78%, transparent);
}
.lab__bg-cell[data-bg="media"] {
background-image: url("/assets/template-elements-demo/img-long.webp");
background-size: cover;
background-position: center;
color: #fff;
}
.lab__bg-cell[data-bg="media"] .lab__caption {
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.lab__bg-cell[data-bg="media"] .neu-card {
background: var(--surface);
color: var(--text);
}
/* ── Состояния ──────────────────────────────────────────────────── */
.lab__state {
display: flex;
flex-direction: column;
}
.lab__depth {
gap: 1.25rem;
}
.lab__depth .neu-card {
min-width: 150px;
}
/* ── Сетка ──────────────────────────────────────────────────────── */
.lab__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.lab__grid--3 {
grid-template-columns: repeat(3, 1fr);
}
.lab__grid--tight {
gap: 0.5rem;
}
@media (max-width: 640px) {
.lab__grid--3 {
grid-template-columns: 1fr;
}
}
/* ── Переключатель стенда ───────────────────────────────────────── */
.lab-switch {
position: fixed;
right: max(1rem, env(safe-area-inset-right, 0px));
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
z-index: 200;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 8px 24px hsla(0, 0%, 0%, 0.25);
font-size: 0.78rem;
}
.lab-switch__btn {
appearance: none;
border: 0;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
font: inherit;
font-family: var(--font-accent);
cursor: pointer;
}
.lab-switch__btn:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.lab-switch__btn[aria-pressed="true"] {
background: var(--accent);
color: var(--on-accent, #fff);
}
.lab-switch__sep {
width: 1px;
height: 1.25rem;
margin: 0 0.15rem;
background: var(--line);
}
/* ── Карточка в карточке ────────────────────────────────────────── */
.lab__nest {
margin-bottom: 1.5rem;
}
/* Внешняя карточка: увеличенные поля и минимум, чтобы вместить вложенные */
.neu-card--nest {
--neu-pad: 1.75rem;
--neu-min-w: 0;
--neu-min-h: 0;
gap: 1rem;
justify-content: flex-start;
}
.neu-card__nest-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.neu-card__nest-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
@media (max-width: 640px) {
.neu-card__nest-grid {
grid-template-columns: 1fr;
}
}
/* ── Шейдер-карточка (прототип Paper Shaders) ───────────────────── */
/* canvas шейдера — фоновый слой ВНУТРИ карточки, под контентом.
pointer-events:none — чтобы клик/hover доходили до самой карточки. */
.contact-card--shader {
overflow: hidden;
isolation: isolate;
}
.card-shader {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
/* нейтральный фон-запас: если WebGL не отрисовал, карточка выглядит как
обычная neu-card (--neu-bg). Когда шейдер рисует — он перекрывает этот фон. */
background: var(--neu-bg);
}
/* ── neu-card + живой шейдер-слой под бликом ───────────────────────────────
Обычная неуморфная карточка (тени снаружи + блик) с анимированным шейдером
ВНУТРИ, под бликом. Слои (снизу вверх):
0 — .card-shader (canvas шейдера)
1 — ::before (блик --neu-glow) И ::after (внутренний объём inset-теней)
2 — контент карточки
Важно: box-shadow родителя рисуется НИЖЕ дочерних слоёв, поэтому шейдер (z:0)
перекрыл бы внутренние тени. Чтобы объём был виден ПОВЕРХ шейдера, внешние
тени (drop) оставляем на карточке, а внутренний объём (inset) выносим на
::after с z-index:1 — он рисуется поверх шейдера. */
.neu-card--shader {
background: transparent; /* фон уходит, шейдер снизу */
isolation: isolate;
/* только внешний объём (приподнятость) */
box-shadow: var(--neu-drop-1), var(--neu-drop-2), var(--neu-drop-3);
}
.neu-card--shader::before {
content: "";
position: absolute;
inset: 0;
z-index: 1; /* поверх шейдера (z:0) */
border-radius: inherit;
pointer-events: none;
background: var(--neu-glow); /* блик из левого верхнего угла */
}
.neu-card--shader::after {
content: "";
position: absolute;
inset: 0;
z-index: 1; /* поверх шейдера, рядом с бликом */
border-radius: inherit;
pointer-events: none;
/* внутренний объём (выпуклость): белая кайма сверху-слева + тёмная снизу-справа */
box-shadow:
var(--neu-in-light-1), var(--neu-in-light-2),
var(--neu-in-dark-1), var(--neu-in-dark-2);
}
.neu-card--shader > *:not(.card-shader) {
position: relative;
z-index: 2; /* контент над бликом/объёмом и шейдером */
}
.contact-card--shader .contact-card__icon,
.contact-card--shader .contact-card__body,
.contact-card--shader .contact-card__corner {
position: relative;
z-index: 1;
}
/* отдельная демо-карточка шейдера */
.shader-demo-card {
position: relative;
overflow: hidden;
isolation: isolate;
width: 100%;
max-width: 360px;
min-height: 220px;
border-radius: var(--radius-card);
background: var(--neu-bg);
box-shadow: var(--neu-shadow);
}
.shader-demo-card--wide {
max-width: 420px;
min-height: 260px;
}
.shader-demo-card__label {
position: absolute;
left: 1rem;
bottom: 0.85rem;
z-index: 1;
font-family: var(--font-accent);
font-size: 0.95rem;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
pointer-events: none;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Стенд: переключение темы и скругления.
* Пишет в localStorage под тем же ключом, что и панель темы сайта,
* поэтому выбранный режим сохранится при переходе на сам сайт.
*/
import { STORAGE_KEY, readThemeState, writeThemeState } from "/js/modules/theme/theme-core.js";
import { ensureThemeFontLoaded } from "/js/modules/theme/theme-fonts.js";
function apply(patch) {
const state = { ...readThemeState(), ...patch };
writeThemeState(state);
const root = document.documentElement;
// Тема: только светлая/тёмная. Режим «system» на стенде не нужен —
// важно видеть обе темы принудительно.
if (state.mode === "system") state.mode = prefersDark() ? "dark" : "light";
root.dataset.theme = state.mode;
if (state.radius) root.dataset.radius = state.radius;
if (state.font) root.dataset.font = state.font;
const labAcc = state[state.mode] || state.light || { h: 210, s: 90, l: 40 };
root.style.setProperty("--theme-h", String(labAcc.h));
root.style.setProperty("--theme-s", String(labAcc.s));
root.style.setProperty("--theme-l", String(labAcc.l));
ensureThemeFontLoaded(state.font);
syncButtons();
}
function prefersDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function bind(attr, key) {
const btns = document.querySelectorAll(`[${attr}]`);
btns.forEach((btn) => {
btn.addEventListener("click", () => {
apply({ [key]: btn.getAttribute(attr) });
});
});
}
function syncButtons() {
const state = readThemeState();
const theme = state.mode === "system" ? (prefersDark() ? "dark" : "light") : state.mode;
document.querySelectorAll("[data-lab-theme]").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.getAttribute("data-lab-theme") === theme ? "true" : "false");
});
document.querySelectorAll("[data-lab-radius]").forEach((btn) => {
btn.setAttribute("aria-pressed", btn.getAttribute("data-lab-radius") === state.radius ? "true" : "false");
});
}
bind("data-lab-theme", "mode");
bind("data-lab-radius", "radius");
syncButtons();
+20
View File
@@ -0,0 +1,20 @@
/**
* Live-reload-клиент для стендов public/_lab.
* Подключается к SSE /_lab/events сервера serve-dev.mjs и перезагружает
* страницу при правке любого css/js/mjs/html в public/.
*
* Вставка в стенд: <script type="module" src="/_lab/live-reload.js"></script>
* Работает только при запущенном `npm run serve` (нет на проде).
*/
(function () {
if (!window.EventSource) return;
const es = new EventSource("/_lab/events");
es.onmessage = (e) => {
if (e.data === "reload") {
es.close();
window.location.reload();
}
};
// Если сервер не поднят — молча уходим, F5 работает как раньше.
es.onerror = () => es.close();
})();
+662
View File
@@ -0,0 +1,662 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="geologica">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Стенд: neu-card</title>
<link rel="stylesheet" href="/css/main.css" />
<link rel="stylesheet" href="/_lab/lab.css" />
</head>
<body>
<div class="page-mesh-bg" aria-hidden="true"></div>
<main class="lab">
<header class="lab__head">
<h1 class="lab__h1">neu-card</h1>
<p class="lab__sub">
Стенд для настройки вида. Сборка не нужна: правим <code>public/css/main.css</code> →
страница перезагружается сама (live-reload). Переключатель темы в правом нижнем углу.
</p>
<nav class="lab__nav" aria-label="Разделы стенда">
<a href="#sec-final">Финал</a>
<a href="#sec-mp">mp-card</a>
<a href="#sec-tokens">Токены</a>
<a href="#sec-bg">Фоны</a>
<a href="#sec-states">Состояния</a>
<a href="#sec-content">Содержимое</a>
<a href="#sec-size">Размеры</a>
<a href="#sec-grid">Сетка</a>
<a href="#sec-nested">Вложенность</a>
<a href="#sec-light">Светлая тема</a>
<a href="#sec-contact">Контакты</a>
<a href="#sec-shader-grid">Матрица</a>
<a href="#sec-all-shaders">Mesh</a>
</nav>
</header>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-final">
<h2 class="lab__h2">Финальный вид</h2>
<p class="lab__note">
Принятые значения: сила объёма <code>0.5</code>, блик <code>0.2</code>, без текстуры.
Обычная неуморфная карточка на нейтральном фоне.
</p>
<div class="lab__row lab__row--wrap lab__depth">
<article class="neu-card neu-card--static">
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Neutral card</span>
<span class="neu-card__meta">Нейтральный фон, выпуклая</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
<button type="button" class="neu-card">
<span class="neu-card__title">Кнопка</span>
<span class="neu-card__meta">Нажми — выпуклость сохраняется</span>
</button>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-mp">
<h2 class="lab__h2">Обычная карточка · mp-card</h2>
<p class="lab__note">
Твоя базовая карточка с сайта — для сравнения с неуморфной рядом. Разметка и стили
взяты из <code>public/css/main.css</code> без изменений.
</p>
<div class="lab__row lab__row--wrap">
<a class="mp-card mp-card--link mp-card--stack" href="#" aria-label="Проект · ссылка">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="/assets/shared/placeholder.webp"
alt=""
width="560"
height="315"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Проект
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">веб-сайт</li>
</ul>
<p class="mp-card__desc">Базовая карточка проекта с обложкой.</p>
</div>
</a>
<div class="mp-card mp-card--stack">
<div class="mp-card__body">
<h3 class="mp-card__title">Текстовая</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">исследование</li>
</ul>
<p class="mp-card__desc">Карточка без обложки — только текст.</p>
</div>
</div>
<a class="mp-card mp-card--link mp-card--compact" href="#" aria-label="Compact · ссылка">
<div class="mp-card__body">
<h3 class="mp-card__title">
Compact
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">дизайн-система</li>
</ul>
<p class="mp-card__desc">Узкая колонка со ссылкой.</p>
</div>
</a>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-tokens">
<h2 class="lab__h2">Токены</h2>
<p class="lab__note">Числа, которыми управляется вид. Значение по умолчанию — в скобках.</p>
<div class="lab__tokens">
<dl class="lab__token">
<dt><code>--neu-bg</code> <span>--bg-elevated</span></dt>
<dd>
Нейтральная заливка: в тёмной теме тёмная, в светлой — светлая. Отличается от
<code>--bg</code> на 3%, поэтому объём читается тенями, а не контрастом.
</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-spread</code> <span>0.5</span></dt>
<dd>Сила объёма. Масштабирует все семь теней разом. <code>0</code> — плоская карточка.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-hover-spread</code> <span>1.5</span></dt>
<dd>Множитель объёма при наведении.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-glow-a</code> <span>0.2</span></dt>
<dd>Сила блика из левого верхнего угла. <code>0</code> — выключен.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-in-light-mult</code> <span>0.55 / 0.4</span></dt>
<dd>
Сила внутреннего блика (тёмная / светлая тема). На светлом фоне белый блик «горит»,
поэтому там он слабее.
</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-in-dark-mult</code> <span>0.75 / 0.5</span></dt>
<dd>Сила внутренней тени (тёмная / светлая тема).</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-press</code> <span>1px</span></dt>
<dd>Смещение вниз при нажатии.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-press-fade</code> <span>0.5</span></dt>
<dd>
Во сколько слабеют внутренние блики при нажатии. Блики НЕ инвертируются — карточка
остаётся выпуклой.
</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-lift</code> <span>-3px</span></dt>
<dd>Подъём при наведении.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-radius</code> <span>--radius-card</span></dt>
<dd>Скругление. По умолчанию следует настройке темы.</dd>
</dl>
<dl class="lab__token">
<dt><code>--neu-pad</code> <span>1.25rem</span></dt>
<dd>Внутренние отступы.</dd>
</dl>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-bg">
<h2 class="lab__h2">Фоны</h2>
<p class="lab__note">
Неуморфизм читается только в паре с подложкой. Карточка построена от <code>--bg</code>,
поэтому на каждом фоне ведёт себя по-разному.
</p>
<div class="lab__bg-grid">
<div class="lab__bg-cell" data-bg="page">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Страница · <code>--bg</code></p>
</div>
<div class="lab__bg-cell" data-bg="elevated">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Поверхность · <code>--bg-elevated</code></p>
</div>
<div class="lab__bg-cell" data-bg="surface">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Карточка на карточке · <code>--surface</code> — худший случай</p>
</div>
<div class="lab__bg-cell" data-bg="accent">
<div class="lab__row">
<article class="neu-card">
<span class="neu-card__title">Neutral</span>
<span class="neu-card__meta">--surface</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Accent</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
<p class="lab__caption">Акцентная подложка · <code>--accent</code></p>
</div>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-states">
<h2 class="lab__h2">Состояния</h2>
<p class="lab__note">
Наведи курсор и нажми — остальное показано принудительно, чтобы видеть их одновременно.
</p>
<div class="lab__row lab__row--wrap">
<div class="lab__state">
<article class="neu-card">
<span class="neu-card__title">Rest</span>
<span class="neu-card__meta">Покой</span>
</article>
<p class="lab__caption">rest</p>
</div>
<div class="lab__state">
<article class="neu-card">
<span class="neu-card__title">Hover</span>
<span class="neu-card__meta">Наведи курсор</span>
</article>
<p class="lab__caption">:hover</p>
</div>
<div class="lab__state">
<article class="neu-card is-pressed">
<span class="neu-card__title">Pressed</span>
<span class="neu-card__meta">Вдавлена</span>
</article>
<p class="lab__caption">:active / .is-pressed</p>
</div>
<div class="lab__state">
<a class="neu-card" href="#sec-states">
<span class="neu-card__title">Focus</span>
<span class="neu-card__meta">Tab или клик</span>
</a>
<p class="lab__caption">:focus-visible</p>
</div>
<div class="lab__state">
<article class="neu-card is-disabled">
<span class="neu-card__title">Disabled</span>
<span class="neu-card__meta">Недоступна</span>
</article>
<p class="lab__caption">.is-disabled</p>
</div>
<div class="lab__state">
<article class="neu-card neu-card--flat">
<span class="neu-card__title">Flat</span>
<span class="neu-card__meta">Без объёма</span>
</article>
<p class="lab__caption">.neu-card--flat</p>
</div>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-content">
<h2 class="lab__h2">Содержимое</h2>
<p class="lab__note">Проверка наполнения: от пустой до переполненной.</p>
<div class="lab__row lab__row--wrap">
<article class="neu-card">
<span class="neu-card__meta">Только мета</span>
</article>
<article class="neu-card">
<span class="neu-card__title">Только заголовок</span>
</article>
<article class="neu-card">
<span class="neu-card__eyebrow">Eyebrow</span>
<span class="neu-card__title">Полный набор</span>
<span class="neu-card__meta">Подпись под заголовком</span>
</article>
<article class="neu-card">
<span class="neu-card__title">Длинный заголовок в несколько строк</span>
<span class="neu-card__meta">
И подпись тоже длинная, чтобы проверить, как карточка растёт по высоте
</span>
</article>
<article class="neu-card neu-card--with-icon">
<span class="neu-card__icon" aria-hidden="true"></span>
<span class="neu-card__title">С иконкой</span>
<span class="neu-card__meta">Иконка сверху</span>
</article>
<article class="neu-card neu-card--with-media">
<img
class="neu-card__media"
src="/assets/template-elements-demo/small-image-1.webp"
alt=""
loading="lazy"
decoding="async"
/>
<span class="neu-card__title">С обложкой</span>
<span class="neu-card__meta">Картинка на всю ширину</span>
</article>
<article class="neu-card neu-card--accent">
<span class="neu-card__title">Акцентная</span>
<span class="neu-card__meta">--accent</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-size">
<h2 class="lab__h2">Размеры</h2>
<p class="lab__note">
Базовый размер задаётся <code>--neu-min-w</code> / <code>--neu-min-h</code>, дальше
карточка растёт от содержимого. В сетке — <code>.neu-card--fluid</code>.
</p>
<div class="lab__row lab__row--wrap">
<article class="neu-card" style="--neu-min-w: 160px; --neu-min-h: 96px">
<span class="neu-card__title">Компактная</span>
<span class="neu-card__meta">160 × 96</span>
</article>
<article class="neu-card">
<span class="neu-card__title">Базовая</span>
<span class="neu-card__meta">200 × 128</span>
</article>
<article class="neu-card" style="--neu-min-w: 280px; --neu-min-h: 180px">
<span class="neu-card__title">Крупная</span>
<span class="neu-card__meta">280 × 180</span>
</article>
<article class="neu-card" style="--neu-pad: 2rem">
<span class="neu-card__title">Широкие поля</span>
<span class="neu-card__meta">--neu-pad: 2rem</span>
</article>
<article class="neu-card" style="--neu-radius: 28px">
<span class="neu-card__title">Сильное скругление</span>
<span class="neu-card__meta">--neu-radius: 28px</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-grid">
<h2 class="lab__h2">Сетка</h2>
<p class="lab__note">
Рядом друг с другом тени соседних карточек накладываются. Здесь видно, не слипаются ли.
</p>
<h3 class="lab__h3">Три в ряд, gap 1rem</h3>
<div class="lab__grid lab__grid--3">
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Проект один</span>
<span class="neu-card__meta">Продуктовый дизайн</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Проект два</span>
<span class="neu-card__meta">Исследование</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Проект три</span>
<span class="neu-card__meta">Дизайн-система</span>
</article>
</div>
<h3 class="lab__h3">Смешанный рост</h3>
<p class="lab__note">Разная высота содержимого — проверка на растяжение по сетке.</p>
<div class="lab__grid lab__grid--3">
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Короткая</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Средняя по объёму</span>
<span class="neu-card__meta">
Здесь подпись занимает две строки, и карточка должна потянуться выше соседней
</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">С обложкой</span>
<span class="neu-card__meta">Ещё выше</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-nested">
<h2 class="lab__h2">Карточка в карточке</h2>
<p class="lab__note">
Нижняя карточка больше и включает в себя верхние. Оценка того, как объём читается, когда
карточка лежит <em>поверх той же самой</em> поверхности, а не на фоне страницы. Это худший
случай для неуморфизма: обе поверхности одинаковые по цвету.
</p>
<div class="lab__nest">
<article class="neu-card neu-card--nest">
<span class="neu-card__eyebrow">Внешняя</span>
<span class="neu-card__title">Карточка-контейнер</span>
<span class="neu-card__meta">Поверхность второго уровня, вложенные карточки внутри</span>
<div class="neu-card__nest-grid">
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Вложенная</span>
<span class="neu-card__meta">Тот же --neu-bg</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Вложенная</span>
<span class="neu-card__meta">Тот же --neu-bg</span>
</article>
<article class="neu-card neu-card--fluid">
<span class="neu-card__title">Вложенная</span>
<span class="neu-card__meta">Тот же --neu-bg</span>
</article>
</div>
<div class="neu-card__nest-row">
<button type="button" class="neu-card">
<span class="neu-card__title">Кнопка</span>
<span class="neu-card__meta">Нажми — не должна проваливаться</span>
</button>
<button type="button" class="neu-card">
<span class="neu-card__title">Кнопка</span>
<span class="neu-card__meta">Выпуклость сохраняется</span>
</button>
</div>
</article>
</div>
<h3 class="lab__h3">То же в светлой теме</h3>
<p class="lab__note">
Внутренние блики настроены раздельно для тёмной и светлой темы — здесь видна разница.
</p>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-light" data-theme="light">
<h2 class="lab__h2">Светлая тема · тень</h2>
<p class="lab__note">
На светлом фоне чистый чёрный 0.25 выглядит грязно. Варианты: цвет тени смещён в
холодный тёмно-серый, альфа внешних теней снижена через <code>--neu-drop-mult</code>.
Эта секция принудительно светлая.
</p>
<div class="lab__row lab__row--wrap lab__depth">
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 47, 53, 75; --neu-drop-mult: 0.5; --neu-in-dark-mult: 0.5">
<span class="neu-card__title">Текущий</span>
<span class="neu-card__meta">47,53,75 · drop 0.5</span>
</article>
<p class="lab__caption">как в main.css</p>
</div>
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 70, 75, 95; --neu-drop-mult: 0.4; --neu-in-dark-mult: 0.4">
<span class="neu-card__title">Мягче</span>
<span class="neu-card__meta">70,75,95 · drop 0.4</span>
</article>
<p class="lab__caption">светлее тень</p>
</div>
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 30, 35, 55; --neu-drop-mult: 0.35; --neu-in-dark-mult: 0.35">
<span class="neu-card__title">Глубже</span>
<span class="neu-card__meta">30,35,55 · drop 0.35</span>
</article>
<p class="lab__caption">темнее, но меньше альфа</p>
</div>
<div class="lab__state">
<article class="neu-card" style="--neu-shadow-color: 0, 0, 0; --neu-drop-mult: 0.5; --neu-in-dark-mult: 0.5">
<span class="neu-card__title">Чёрный</span>
<span class="neu-card__meta">0,0,0 · drop 0.5</span>
</article>
<p class="lab__caption">было грязно</p>
</div>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-contact">
<h2 class="lab__h2">Контактные карточки</h2>
<p class="lab__note">
Оригинальная <code>.contact-card</code> с сайта, теперь с неуморфной основой
(объём как у <code>.neu-card</code>). Бренд-цвета через модификаторы
<code>--brand-tg / --brand-vk / --brand-ya / --brand-email</code>.
Цветное наложение — радиальный градиент <code>::before</code> из <code>--contact-brand</code>.
</p>
<div class="lab__row lab__row--wrap lab__depth contact-grid">
<li>
<a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer">
<span class="contact-card__corner" aria-hidden="true"></span>
<span class="contact-card__icon" aria-hidden="true"><img src="/assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span>
<span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">Отвечу сегодня</span></span>
</a>
</li>
<li>
<a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer">
<span class="contact-card__corner" aria-hidden="true"></span>
<span class="contact-card__icon" aria-hidden="true"><img src="/assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span>
<span class="contact-card__body"><span class="contact-card__name">ВКонтакте</span><span class="contact-card__hint">Стабильный коннект</span></span>
</a>
</li>
<li>
<a class="contact-card contact-card--link contact-card--brand-ya" href="https://ya.ru" rel="noopener noreferrer">
<span class="contact-card__corner" aria-hidden="true"></span>
<span class="contact-card__icon" aria-hidden="true"><img src="/assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span>
<span class="contact-card__body"><span class="contact-card__name">Яндекс Телемост</span><span class="contact-card__hint">Видеозвонок</span></span>
</a>
</li>
<li>
<button type="button" class="contact-card contact-card--action contact-card--brand-email">
<span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span>
<span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span>
<span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint">nik@mamadaevv.site</span></span>
</button>
</li>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-shader-grid">
<h2 class="lab__h2">Матрица · neu-card + шейдер под бликом</h2>
<p class="lab__note">
Обычная неуморфная карточка (тени снаружи, блик из угла) с живым шейдером
<strong>внутри</strong>, под бликом. Слева — чистая neu-card для сравнения,
справа — та же карточка с анимированным mesh-слоем. Объём и блик сохранены,
шейдер только подсвечивает поверхность.
</p>
<h3 class="lab__h3">Сетка 3 × 2</h3>
<div class="lab__grid lab__grid--3 lab__matrix">
<!-- чистая neu-card (ряд 1 — образец без шейдера) -->
<article class="neu-card neu-card--fluid">
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Обычная</span>
<span class="neu-card__meta">только тени и блик</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="demo" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · demo</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="email" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · email</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<!-- ряд 2 — разные бренды -->
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="tg" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · tg</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="vk" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · vk</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
<article class="neu-card neu-card--fluid neu-card--shader">
<span class="card-shader" data-shader="ya" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Проект</span>
<span class="neu-card__title">Шейдер · ya</span>
<span class="neu-card__meta">mesh под бликом</span>
</article>
</div>
<h3 class="lab__h3">Одиночная крупная</h3>
<div class="lab__row lab__row--wrap lab__depth">
<article class="neu-card neu-card--shader" style="--neu-min-w: 280px; --neu-min-h: 180px">
<span class="card-shader" data-shader="demo" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Кейс</span>
<span class="neu-card__title">Neu + shader</span>
<span class="neu-card__meta">объём снаружи, анимация внутри</span>
</article>
<article class="neu-card neu-card--static neu-card--shader" style="--neu-min-w: 280px; --neu-min-h: 180px">
<span class="card-shader" data-shader="email" aria-hidden="true"></span>
<span class="neu-card__eyebrow">Кейс</span>
<span class="neu-card__title">Static + shader</span>
<span class="neu-card__meta">блик и анимация, без hover-отклика</span>
</article>
</div>
</section>
<!-- ────────────────────────────────────────────────────────── -->
<section class="lab__sec" id="sec-all-shaders">
<h2 class="lab__h2">Mesh gradient</h2>
<p class="lab__note">
Единственный используемый шейдер — анимированный mesh gradient (цветные пятна
плавно перетекают). Ниже — карточка-пример только с ним. Контактные и матричные
карточки выше используют тот же шейдер с брендовыми цветами.
</p>
<h3 class="lab__h3">Пример · mesh gradient</h3>
<div class="lab__row lab__depth">
<article class="shader-demo-card shader-demo-card--wide" data-shader-cell="demo">
<span class="card-shader" data-shader="demo" aria-hidden="true"></span>
<span class="shader-demo-card__label">mesh gradient</span>
</article>
</div>
</section>
</main>
<div class="lab-switch" role="group" aria-label="Настройки стенда">
<button type="button" class="lab-switch__btn" data-lab-theme="light">Светлая</button>
<button type="button" class="lab-switch__btn" data-lab-theme="dark">Тёмная</button>
<span class="lab-switch__sep" aria-hidden="true"></span>
<button type="button" class="lab-switch__btn" data-lab-radius="soft">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="medium">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="strong">R·</button>
</div>
<script type="module" src="/_lab/lab.js"></script>
<script type="module" src="/_lab/shader-demo.js"></script>
<script type="module" src="/_lab/live-reload.js"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<!DOCTYPE html><html><body style="margin:0;background:#221C89">
<div id="n" style="width:320px;height:320px;background-size:160px 160px;background-image:url(&quot;data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27160%27%20height%3D%27160%27%3E%3Cfilter%20id%3D%27n%27%20x%3D%270%27%20y%3D%270%27%20width%3D%27100%25%27%20height%3D%27100%25%27%20color-interpolation-filters%3D%27sRGB%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%272%27%20numOctaves%3D%273%27%20stitchTiles%3D%27stitch%27%20seed%3D%277403%27%2F%3E%3CfeComponentTransfer%3E%3CfeFuncR%20type%3D%27linear%27%20slope%3D%272%27%20intercept%3D%27-0.5%27%2F%3E%3CfeFuncG%20type%3D%27linear%27%20slope%3D%272%27%20intercept%3D%27-0.5%27%2F%3E%3CfeFuncB%20type%3D%27linear%27%20slope%3D%272%27%20intercept%3D%27-0.5%27%2F%3E%3CfeFuncA%20type%3D%27discrete%27%20tableValues%3D%271%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%27%2F%3E%3C%2FfeComponentTransfer%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%27table%27%20tableValues%3D%270%200.15%27%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Crect%20width%3D%27160%27%20height%3D%27160%27%20filter%3D%27url%28%23n%29%27%2F%3E%3C%2Fsvg%3E&quot;)"></div>
</body></html>
+961
View File
@@ -0,0 +1,961 @@
// node_modules/@paper-design/shaders/dist/vertex-shader.js
var vertexShaderSource = `#version 300 es
precision mediump float;
layout(location = 0) in vec4 a_position;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform float u_imageAspectRatio;
uniform float u_originX;
uniform float u_originY;
uniform float u_worldWidth;
uniform float u_worldHeight;
uniform float u_fit;
uniform float u_scale;
uniform float u_rotation;
uniform float u_offsetX;
uniform float u_offsetY;
out vec2 v_objectUV;
out vec2 v_objectBoxSize;
out vec2 v_responsiveUV;
out vec2 v_responsiveBoxGivenSize;
out vec2 v_patternUV;
out vec2 v_patternBoxSize;
out vec2 v_imageUV;
vec3 getBoxSize(float boxRatio, vec2 givenBoxSize) {
vec2 box = vec2(0.);
// fit = none
box.x = boxRatio * min(givenBoxSize.x / boxRatio, givenBoxSize.y);
float noFitBoxWidth = box.x;
if (u_fit == 1.) { // fit = contain
box.x = boxRatio * min(u_resolution.x / boxRatio, u_resolution.y);
} else if (u_fit == 2.) { // fit = cover
box.x = boxRatio * max(u_resolution.x / boxRatio, u_resolution.y);
}
box.y = box.x / boxRatio;
return vec3(box, noFitBoxWidth);
}
void main() {
gl_Position = a_position;
vec2 uv = gl_Position.xy * .5;
vec2 boxOrigin = vec2(.5 - u_originX, u_originY - .5);
vec2 givenBoxSize = vec2(u_worldWidth, u_worldHeight);
givenBoxSize = max(givenBoxSize, vec2(1.)) * u_pixelRatio;
float r = u_rotation * 3.14159265358979323846 / 180.;
mat2 graphicRotation = mat2(cos(r), sin(r), -sin(r), cos(r));
vec2 graphicOffset = vec2(-u_offsetX, u_offsetY);
// ===================================================
float fixedRatio = 1.;
vec2 fixedRatioBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
v_objectBoxSize = getBoxSize(fixedRatio, fixedRatioBoxGivenSize).xy;
vec2 objectWorldScale = u_resolution.xy / v_objectBoxSize;
v_objectUV = uv;
v_objectUV *= objectWorldScale;
v_objectUV += boxOrigin * (objectWorldScale - 1.);
v_objectUV += graphicOffset;
v_objectUV /= u_scale;
v_objectUV = graphicRotation * v_objectUV;
// ===================================================
v_responsiveBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
float responsiveRatio = v_responsiveBoxGivenSize.x / v_responsiveBoxGivenSize.y;
vec2 responsiveBoxSize = getBoxSize(responsiveRatio, v_responsiveBoxGivenSize).xy;
vec2 responsiveBoxScale = u_resolution.xy / responsiveBoxSize;
#ifdef ADD_HELPERS
v_responsiveHelperBox = uv;
v_responsiveHelperBox *= responsiveBoxScale;
v_responsiveHelperBox += boxOrigin * (responsiveBoxScale - 1.);
#endif
v_responsiveUV = uv;
v_responsiveUV *= responsiveBoxScale;
v_responsiveUV += boxOrigin * (responsiveBoxScale - 1.);
v_responsiveUV += graphicOffset;
v_responsiveUV /= u_scale;
v_responsiveUV.x *= responsiveRatio;
v_responsiveUV = graphicRotation * v_responsiveUV;
v_responsiveUV.x /= responsiveRatio;
// ===================================================
float patternBoxRatio = givenBoxSize.x / givenBoxSize.y;
vec2 patternBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
patternBoxRatio = patternBoxGivenSize.x / patternBoxGivenSize.y;
vec3 boxSizeData = getBoxSize(patternBoxRatio, patternBoxGivenSize);
v_patternBoxSize = boxSizeData.xy;
float patternBoxNoFitBoxWidth = boxSizeData.z;
vec2 patternBoxScale = u_resolution.xy / v_patternBoxSize;
v_patternUV = uv;
v_patternUV += graphicOffset / patternBoxScale;
v_patternUV += boxOrigin;
v_patternUV -= boxOrigin / patternBoxScale;
v_patternUV *= u_resolution.xy;
v_patternUV /= u_pixelRatio;
if (u_fit > 0.) {
v_patternUV *= (patternBoxNoFitBoxWidth / v_patternBoxSize.x);
}
v_patternUV /= u_scale;
v_patternUV = graphicRotation * v_patternUV;
v_patternUV += boxOrigin / patternBoxScale;
v_patternUV -= boxOrigin;
// x100 is a default multiplier between vertex and fragmant shaders
// we use it to avoid UV presision issues
v_patternUV *= .01;
// ===================================================
vec2 imageBoxSize;
if (u_fit == 1.) { // contain
imageBoxSize.x = min(u_resolution.x / u_imageAspectRatio, u_resolution.y) * u_imageAspectRatio;
} else if (u_fit == 2.) { // cover
imageBoxSize.x = max(u_resolution.x / u_imageAspectRatio, u_resolution.y) * u_imageAspectRatio;
} else {
imageBoxSize.x = min(10.0, 10.0 / u_imageAspectRatio * u_imageAspectRatio);
}
imageBoxSize.y = imageBoxSize.x / u_imageAspectRatio;
vec2 imageBoxScale = u_resolution.xy / imageBoxSize;
v_imageUV = uv;
v_imageUV *= imageBoxScale;
v_imageUV += boxOrigin * (imageBoxScale - 1.);
v_imageUV += graphicOffset;
v_imageUV /= u_scale;
v_imageUV.x *= u_imageAspectRatio;
v_imageUV = graphicRotation * v_imageUV;
v_imageUV.x /= u_imageAspectRatio;
v_imageUV += .5;
v_imageUV.y = 1. - v_imageUV.y;
}`;
// node_modules/@paper-design/shaders/dist/shader-mount.js
var DEFAULT_MAX_PIXEL_COUNT = 1920 * 1080 * 4;
var ShaderMount = class {
parentElement;
canvasElement;
gl;
program = null;
uniformLocations = {};
/** The fragment shader that we are using */
fragmentShader;
/** Stores the RAF for the render loop */
rafId = null;
/** Time of the last rendered frame */
lastRenderTime = 0;
/** Total time that we have played any animation, passed as a uniform to the shader for time-based VFX */
currentFrame = 0;
/** The speed that we progress through animation time (multiplies by delta time every update). Allows negatives to play in reverse. If set to 0, rAF will stop entirely so static shaders have no recurring performance costs */
speed = 0;
/** Actual speed used that accounts for visibility: we pause the shader if the tab is hidden or the element out of the viewport */
currentSpeed = 0;
/** Uniforms that are provided by the user for the specific shader being mounted (not including uniforms that this Mount adds, like time and resolution) */
providedUniforms;
/** Names of the uniforms that should have mipmaps generated for them */
mipmaps = [];
/** Just a sanity check to make sure frames don't run after we're disposed */
hasBeenDisposed = false;
/** If the resolution of the canvas has changed since the last render */
resolutionChanged = true;
/** Store textures that are provided by the user */
textures = /* @__PURE__ */ new Map();
minPixelRatio;
maxPixelCount;
isSafari = isSafari();
uniformCache = {};
textureUnitMap = /* @__PURE__ */ new Map();
ownerDocument;
constructor(parentElement, fragmentShader, uniforms, webGlContextAttributes, speed = 0, frame = 0, minPixelRatio = 2, maxPixelCount = DEFAULT_MAX_PIXEL_COUNT, mipmaps = []) {
if (parentElement?.nodeType === 1) {
this.parentElement = parentElement;
} else {
throw new Error("Paper Shaders: parent element must be an HTMLElement");
}
this.ownerDocument = parentElement.ownerDocument;
if (!this.ownerDocument.querySelector("style[data-paper-shader]")) {
const styleElement = this.ownerDocument.createElement("style");
styleElement.innerHTML = defaultStyle;
styleElement.setAttribute("data-paper-shader", "");
this.ownerDocument.head.prepend(styleElement);
}
const canvasElement = this.ownerDocument.createElement("canvas");
this.canvasElement = canvasElement;
this.parentElement.prepend(canvasElement);
this.fragmentShader = fragmentShader;
this.providedUniforms = uniforms;
this.mipmaps = mipmaps;
this.currentFrame = frame;
this.minPixelRatio = minPixelRatio;
this.maxPixelCount = maxPixelCount;
const gl = canvasElement.getContext("webgl2", webGlContextAttributes);
if (!gl) {
throw new Error("Paper Shaders: WebGL is not supported in this browser");
}
this.gl = gl;
this.initProgram();
this.setupPositionAttribute();
this.setupUniforms();
this.setUniformValues(this.providedUniforms);
this.setupResizeObserver();
visualViewport?.addEventListener("resize", this.handleVisualViewportChange);
this.setupIntersectionObserver();
this.setSpeed(speed);
this.parentElement.setAttribute("data-paper-shader", "");
this.parentElement.paperShaderMount = this;
this.ownerDocument.addEventListener("visibilitychange", this.handleDocumentVisibilityChange);
}
initProgram = () => {
const program = createProgram(this.gl, vertexShaderSource, this.fragmentShader);
if (!program) return;
this.program = program;
};
setupPositionAttribute = () => {
const positionAttributeLocation = this.gl.getAttribLocation(this.program, "a_position");
const positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW);
this.gl.enableVertexAttribArray(positionAttributeLocation);
this.gl.vertexAttribPointer(positionAttributeLocation, 2, this.gl.FLOAT, false, 0, 0);
};
setupUniforms = () => {
const uniformLocations = {
u_time: this.gl.getUniformLocation(this.program, "u_time"),
u_pixelRatio: this.gl.getUniformLocation(this.program, "u_pixelRatio"),
u_resolution: this.gl.getUniformLocation(this.program, "u_resolution")
};
Object.entries(this.providedUniforms).forEach(([key, value]) => {
uniformLocations[key] = this.gl.getUniformLocation(this.program, key);
if (value instanceof HTMLImageElement) {
const aspectRatioUniformName = `${key}AspectRatio`;
uniformLocations[aspectRatioUniformName] = this.gl.getUniformLocation(this.program, aspectRatioUniformName);
}
});
this.uniformLocations = uniformLocations;
};
/**
* The scale that we should render at.
* - Used to target 2x rendering even on 1x screens for better antialiasing
* - Prevents the virtual resolution from going beyond the maximum resolution
* - Accounts for the page zoom level so we render in physical device pixels rather than CSS pixels
*/
renderScale = 1;
parentWidth = 0;
parentHeight = 0;
parentDevicePixelWidth = 0;
parentDevicePixelHeight = 0;
devicePixelsSupported = false;
intersectionObserver = null;
isInViewport = true;
resizeObserver = null;
setupResizeObserver = () => {
this.resizeObserver = new ResizeObserver(([entry]) => {
if (entry?.borderBoxSize[0]) {
const physicalPixelSize = entry.devicePixelContentBoxSize?.[0];
if (physicalPixelSize !== void 0) {
this.devicePixelsSupported = true;
this.parentDevicePixelWidth = physicalPixelSize.inlineSize;
this.parentDevicePixelHeight = physicalPixelSize.blockSize;
}
this.parentWidth = entry.borderBoxSize[0].inlineSize;
this.parentHeight = entry.borderBoxSize[0].blockSize;
}
this.handleResize();
});
this.resizeObserver.observe(this.parentElement);
};
setupIntersectionObserver = () => {
const view = this.ownerDocument.defaultView;
if (!view?.IntersectionObserver) return;
this.intersectionObserver = new view.IntersectionObserver(([entry]) => {
this.isInViewport = entry?.isIntersecting ?? true;
this.updateCurrentSpeed();
});
this.intersectionObserver.observe(this.parentElement);
};
// Visual viewport resize handler, mainly used to react to browser zoom changes.
// Resize observer by itself does not react to pinch zoom, and although it usually
// reacts to classic browser zoom, it's not guaranteed in edge cases.
// Since timing between visual viewport changes and resize observer is complex
// and because we'd like to know the device pixel sizes of elements, we just restart
// the observer to get a guaranteed fresh callback regardless if it would have triggered or not.
handleVisualViewportChange = () => {
this.resizeObserver?.disconnect();
this.setupResizeObserver();
};
/** Resize handler for when the container div changes size or the max pixel count changes and we want to resize our canvas to match */
handleResize = () => {
let targetPixelWidth = 0;
let targetPixelHeight = 0;
const dpr = Math.max(1, window.devicePixelRatio);
const pinchZoom = visualViewport?.scale ?? 1;
if (this.devicePixelsSupported) {
const scaleToMeetMinPixelRatio = Math.max(1, this.minPixelRatio / dpr);
targetPixelWidth = this.parentDevicePixelWidth * scaleToMeetMinPixelRatio * pinchZoom;
targetPixelHeight = this.parentDevicePixelHeight * scaleToMeetMinPixelRatio * pinchZoom;
} else {
let targetRenderScale = Math.max(dpr, this.minPixelRatio) * pinchZoom;
if (this.isSafari) {
const zoomLevel = bestGuessBrowserZoom(this.ownerDocument);
targetRenderScale *= Math.max(1, zoomLevel);
}
targetPixelWidth = Math.round(this.parentWidth) * targetRenderScale;
targetPixelHeight = Math.round(this.parentHeight) * targetRenderScale;
}
const maxPixelCountHeadroom = Math.sqrt(this.maxPixelCount) / Math.sqrt(targetPixelWidth * targetPixelHeight);
const scaleToMeetMaxPixelCount = Math.min(1, maxPixelCountHeadroom);
const newWidth = Math.round(targetPixelWidth * scaleToMeetMaxPixelCount);
const newHeight = Math.round(targetPixelHeight * scaleToMeetMaxPixelCount);
const newRenderScale = newWidth / Math.round(this.parentWidth);
if (this.canvasElement.width !== newWidth || this.canvasElement.height !== newHeight || this.renderScale !== newRenderScale) {
this.renderScale = newRenderScale;
this.canvasElement.width = newWidth;
this.canvasElement.height = newHeight;
this.resolutionChanged = true;
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
this.render(performance.now());
}
};
render = (currentTime) => {
if (this.hasBeenDisposed) return;
if (this.program === null) {
console.warn("Tried to render before program or gl was initialized");
return;
}
const dt = currentTime - this.lastRenderTime;
this.lastRenderTime = currentTime;
if (this.currentSpeed !== 0) {
this.currentFrame += dt * this.currentSpeed;
}
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
this.gl.useProgram(this.program);
this.gl.uniform1f(this.uniformLocations.u_time, this.currentFrame * 1e-3);
if (this.resolutionChanged) {
this.gl.uniform2f(this.uniformLocations.u_resolution, this.gl.canvas.width, this.gl.canvas.height);
this.gl.uniform1f(this.uniformLocations.u_pixelRatio, this.renderScale);
this.resolutionChanged = false;
}
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
if (this.currentSpeed !== 0) {
this.requestRender();
} else {
this.rafId = null;
}
};
requestRender = () => {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(this.render);
};
/** Creates a texture from an image and sets it into a uniform value */
setTextureUniform = (uniformName, image) => {
if (!image.complete || image.naturalWidth === 0) {
throw new Error(`Paper Shaders: image for uniform ${uniformName} must be fully loaded`);
}
const existingTexture = this.textures.get(uniformName);
if (existingTexture) {
this.gl.deleteTexture(existingTexture);
}
if (!this.textureUnitMap.has(uniformName)) {
this.textureUnitMap.set(uniformName, this.textureUnitMap.size);
}
const textureUnit = this.textureUnitMap.get(uniformName);
this.gl.activeTexture(this.gl.TEXTURE0 + textureUnit);
const texture = this.gl.createTexture();
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, image);
if (this.mipmaps.includes(uniformName)) {
this.gl.generateMipmap(this.gl.TEXTURE_2D);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
}
const error = this.gl.getError();
if (error !== this.gl.NO_ERROR || texture === null) {
console.error("Paper Shaders: WebGL error when uploading texture:", error);
return;
}
this.textures.set(uniformName, texture);
const location = this.uniformLocations[uniformName];
if (location) {
this.gl.uniform1i(location, textureUnit);
const aspectRatioUniformName = `${uniformName}AspectRatio`;
const aspectRatioLocation = this.uniformLocations[aspectRatioUniformName];
if (aspectRatioLocation) {
const aspectRatio = image.naturalWidth / image.naturalHeight;
this.gl.uniform1f(aspectRatioLocation, aspectRatio);
}
}
};
/** Utility: recursive equality test for all the uniforms */
areUniformValuesEqual = (a, b) => {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
return a.every((val, i) => this.areUniformValuesEqual(val, b[i]));
}
return false;
};
/** Sets the provided uniform values into the WebGL program, can be a partial list of uniforms that have changed */
setUniformValues = (updatedUniforms) => {
this.gl.useProgram(this.program);
Object.entries(updatedUniforms).forEach(([key, value]) => {
let cacheValue = value;
if (value instanceof HTMLImageElement) {
cacheValue = `${value.src.slice(0, 200)}|${value.naturalWidth}x${value.naturalHeight}`;
}
if (this.areUniformValuesEqual(this.uniformCache[key], cacheValue)) return;
this.uniformCache[key] = cacheValue;
const location = this.uniformLocations[key];
if (!location) {
console.warn(`Uniform location for ${key} not found`);
return;
}
if (value instanceof HTMLImageElement) {
this.setTextureUniform(key, value);
} else if (Array.isArray(value)) {
let flatArray = null;
let valueLength = null;
if (value[0] !== void 0 && Array.isArray(value[0])) {
const firstChildLength = value[0].length;
if (value.every((arr) => arr.length === firstChildLength)) {
flatArray = value.flat();
valueLength = firstChildLength;
} else {
console.warn(`All child arrays must be the same length for ${key}`);
return;
}
} else {
flatArray = value;
valueLength = flatArray.length;
}
switch (valueLength) {
case 2:
this.gl.uniform2fv(location, flatArray);
break;
case 3:
this.gl.uniform3fv(location, flatArray);
break;
case 4:
this.gl.uniform4fv(location, flatArray);
break;
case 9:
this.gl.uniformMatrix3fv(location, false, flatArray);
break;
case 16:
this.gl.uniformMatrix4fv(location, false, flatArray);
break;
default:
console.warn(`Unsupported uniform array length: ${valueLength}`);
}
} else if (typeof value === "number") {
this.gl.uniform1f(location, value);
} else if (typeof value === "boolean") {
this.gl.uniform1i(location, value ? 1 : 0);
} else {
console.warn(`Unsupported uniform type for ${key}: ${typeof value}`);
}
});
};
/** Gets the current total animation time from 0ms */
getCurrentFrame = () => {
return this.currentFrame;
};
/** Set a frame to get a deterministic result, frames are literally just milliseconds from zero since the animation started */
setFrame = (newFrame) => {
this.currentFrame = newFrame;
this.lastRenderTime = performance.now();
this.render(performance.now());
};
/** Set an animation speed (or 0 to stop animation) */
setSpeed = (newSpeed = 1) => {
this.speed = newSpeed;
this.updateCurrentSpeed();
};
/** Apply the target speed, pausing (0) while the tab is hidden or the element is out of the viewport */
updateCurrentSpeed = () => {
this.setCurrentSpeed(this.ownerDocument.hidden || !this.isInViewport ? 0 : this.speed);
};
setCurrentSpeed = (newSpeed) => {
this.currentSpeed = newSpeed;
if (this.rafId === null && newSpeed !== 0) {
this.lastRenderTime = performance.now();
this.rafId = requestAnimationFrame(this.render);
}
if (this.rafId !== null && newSpeed === 0) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
};
/** Set the maximum pixel count for the shader, this will limit the number of pixels that will be rendered */
setMaxPixelCount = (newMaxPixelCount = DEFAULT_MAX_PIXEL_COUNT) => {
this.maxPixelCount = newMaxPixelCount;
this.handleResize();
};
/** Set the minimum pixel ratio for the shader */
setMinPixelRatio = (newMinPixelRatio = 2) => {
this.minPixelRatio = newMinPixelRatio;
this.handleResize();
};
/** Update the uniforms that are provided by the outside shader, can be a partial set with only the uniforms that have changed */
setUniforms = (newUniforms) => {
this.setUniformValues(newUniforms);
this.providedUniforms = { ...this.providedUniforms, ...newUniforms };
this.render(performance.now());
};
handleDocumentVisibilityChange = () => {
this.updateCurrentSpeed();
};
/** Dispose of the shader mount, cleaning up all of the WebGL resources */
dispose = () => {
this.hasBeenDisposed = true;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.gl && this.program) {
this.textures.forEach((texture) => {
this.gl.deleteTexture(texture);
});
this.textures.clear();
this.gl.deleteProgram(this.program);
this.program = null;
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, null);
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
this.gl.getError();
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
visualViewport?.removeEventListener("resize", this.handleVisualViewportChange);
this.ownerDocument.removeEventListener("visibilitychange", this.handleDocumentVisibilityChange);
this.uniformLocations = {};
this.canvasElement.remove();
delete this.parentElement.paperShaderMount;
};
};
function createShader(gl, type, source) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vertexShaderSource2, fragmentShaderSource) {
const format = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.MEDIUM_FLOAT);
const precision = format ? format.precision : null;
if (precision && precision < 23) {
vertexShaderSource2 = vertexShaderSource2.replace(/precision\s+(lowp|mediump)\s+float;/g, "precision highp float;");
fragmentShaderSource = fragmentShaderSource.replace(/precision\s+(lowp|mediump)\s+float/g, "precision highp float").replace(/\b(uniform|varying|attribute)\s+(lowp|mediump)\s+(\w+)/g, "$1 highp $3");
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource2);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
if (!vertexShader || !fragmentShader) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Unable to initialize the shader program: " + gl.getProgramInfoLog(program));
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
gl.detachShader(program, vertexShader);
gl.detachShader(program, fragmentShader);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return program;
}
var defaultStyle = `@layer paper-shaders {
:where([data-paper-shader]) {
isolation: isolate;
position: relative;
& canvas {
contain: strict;
display: block;
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
border-radius: inherit;
corner-shape: inherit;
}
}
}`;
function isSafari() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("safari") && !ua.includes("chrome") && !ua.includes("android");
}
function bestGuessBrowserZoom(doc) {
const viewportScale = visualViewport?.scale ?? 1;
const viewportWidth = visualViewport?.width ?? window.innerWidth;
const scrollbarWidth = window.innerWidth - doc.documentElement.clientWidth;
const innerWidth = viewportScale * viewportWidth + scrollbarWidth;
const ratio = outerWidth / innerWidth;
const zoomPercentageRounded = Math.round(100 * ratio);
if (zoomPercentageRounded % 5 === 0) {
return zoomPercentageRounded / 100;
}
if (zoomPercentageRounded === 33) {
return 1 / 3;
}
if (zoomPercentageRounded === 67) {
return 2 / 3;
}
if (zoomPercentageRounded === 133) {
return 4 / 3;
}
return ratio;
}
// node_modules/@paper-design/shaders/dist/shader-utils.js
var declarePI = `
#define TWO_PI 6.28318530718
#define PI 3.14159265358979323846
`;
var rotation2 = `
vec2 rotate(vec2 uv, float th) {
return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv;
}
`;
var textureRandomizerR = `
float randomR(vec2 p) {
vec2 uv = floor(p) / 100. + .5;
return texture(u_noiseTexture, fract(uv)).r;
}
`;
var fiberNoise = `
float fiberRandom(vec2 p) {
vec2 uv = floor(p) / 100.;
return texture(u_noiseTexture, fract(uv)).b;
}
float fiberValueNoise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = fiberRandom(i);
float b = fiberRandom(i + vec2(1.0, 0.0));
float c = fiberRandom(i + vec2(0.0, 1.0));
float d = fiberRandom(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
float x1 = mix(a, b, u.x);
float x2 = mix(c, d, u.x);
return mix(x1, x2, u.y);
}
float fiberNoiseFbm(in vec2 n, vec2 seedOffset) {
float total = 0.0, amplitude = 1.;
for (int i = 0; i < 4; i++) {
n = rotate(n, .7);
total += fiberValueNoise(n + seedOffset) * amplitude;
n *= 2.;
amplitude *= 0.6;
}
return total;
}
float fiberNoise(vec2 uv, vec2 seedOffset) {
float epsilon = 0.001;
float n1 = fiberNoiseFbm(uv + vec2(epsilon, 0.0), seedOffset);
float n2 = fiberNoiseFbm(uv - vec2(epsilon, 0.0), seedOffset);
float n3 = fiberNoiseFbm(uv + vec2(0.0, epsilon), seedOffset);
float n4 = fiberNoiseFbm(uv - vec2(0.0, epsilon), seedOffset);
return length(vec2(n1 - n2, n3 - n4)) / (2.0 * epsilon);
}
`;
// node_modules/@paper-design/shaders/dist/shaders/paper-texture.js
var paperTextureFragmentShader = `#version 300 es
precision mediump float;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform vec4 u_colorFront;
uniform vec4 u_colorBack;
uniform sampler2D u_image;
uniform float u_imageAspectRatio;
uniform float u_contrast;
uniform float u_roughness;
uniform float u_fiber;
uniform float u_fiberSize;
uniform float u_crumples;
uniform float u_crumpleSize;
uniform float u_folds;
uniform float u_foldCount;
uniform float u_drops;
uniform float u_seed;
uniform float u_fade;
uniform sampler2D u_noiseTexture;
in vec2 v_imageUV;
out vec4 fragColor;
float getUvFrame(vec2 uv) {
float aax = 2. * fwidth(uv.x);
float aay = 2. * fwidth(uv.y);
float left = smoothstep(0., aax, uv.x);
float right = 1. - smoothstep(1. - aax, 1., uv.x);
float bottom = smoothstep(0., aay, uv.y);
float top = 1. - smoothstep(1. - aay, 1., uv.y);
return left * right * bottom * top;
}
${declarePI}
${rotation2}
${textureRandomizerR}
float valueNoise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = randomR(i);
float b = randomR(i + vec2(1.0, 0.0));
float c = randomR(i + vec2(0.0, 1.0));
float d = randomR(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
float x1 = mix(a, b, u.x);
float x2 = mix(c, d, u.x);
return mix(x1, x2, u.y);
}
float fbm(vec2 n) {
float total = 0.0, amplitude = .4;
for (int i = 0; i < 3; i++) {
total += valueNoise(n) * amplitude;
n *= 1.99;
amplitude *= 0.65;
}
return total;
}
float randomG(vec2 p) {
vec2 uv = floor(p) / 50. + .5;
return texture(u_noiseTexture, fract(uv)).g;
}
float roughness(vec2 p) {
p *= .1;
float o = 0.;
for (float i = 0.; ++i < 4.; p *= 2.1) {
vec4 w = vec4(floor(p), ceil(p));
vec2 f = fract(p);
o += mix(
mix(randomG(w.xy), randomG(w.xw), f.y),
mix(randomG(w.zy), randomG(w.zw), f.y),
f.x);
o += .2 / exp(2. * abs(sin(.2 * p.x + .5 * p.y)));
}
return o / 3.;
}
${fiberNoise}
vec2 randomGB(vec2 p) {
vec2 uv = floor(p) / 50. + .5;
return texture(u_noiseTexture, fract(uv)).gb;
}
float crumpledNoise(vec2 t, float pw) {
vec2 p = floor(t);
float wsum = 0.;
float cl = 0.;
for (int y = -1; y < 2; y += 1) {
for (int x = -1; x < 2; x += 1) {
vec2 b = vec2(float(x), float(y));
vec2 q = b + p;
vec2 q2 = q - floor(q / 8.) * 8.;
vec2 c = q + randomGB(q2);
vec2 r = c - t;
float w = pow(smoothstep(0., 1., 1. - abs(r.x)), pw) * pow(smoothstep(0., 1., 1. - abs(r.y)), pw);
cl += (.5 + .5 * sin((q2.x + q2.y * 5.) * 8.)) * w;
wsum += w;
}
}
return pow(wsum != 0.0 ? cl / wsum : 0.0, .5) * 2.;
}
float crumplesShape(vec2 uv) {
return crumpledNoise(uv * .25, 16.) * crumpledNoise(uv * .5, 2.);
}
vec2 folds(vec2 uv) {
vec3 pp = vec3(0.);
float l = 9.;
for (float i = 0.; i < 15.; i++) {
if (i >= u_foldCount) break;
vec2 rand = randomGB(vec2(i, i * u_seed));
float an = rand.x * TWO_PI;
vec2 p = vec2(cos(an), sin(an)) * rand.y;
float dist = distance(uv, p);
l = min(l, dist);
if (l == dist) {
pp.xy = (uv - p.xy);
pp.z = dist;
}
}
return mix(pp.xy, vec2(0.), pow(pp.z, .25));
}
float drops(vec2 uv) {
vec2 iDropsUV = floor(uv);
vec2 fDropsUV = fract(uv);
float dropsMinDist = 1.;
for (int j = -1; j <= 1; j++) {
for (int i = -1; i <= 1; i++) {
vec2 neighbor = vec2(float(i), float(j));
vec2 offset = randomGB(iDropsUV + neighbor);
offset = .5 + .5 * sin(10. * u_seed + TWO_PI * offset);
vec2 pos = neighbor + offset - fDropsUV;
float dist = length(pos);
dropsMinDist = min(dropsMinDist, dropsMinDist*dist);
}
}
return 1. - smoothstep(.05, .09, pow(dropsMinDist, .5));
}
float lst(float edge0, float edge1, float x) {
return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
}
void main() {
vec2 imageUV = v_imageUV;
vec2 patternUV = v_imageUV - .5;
patternUV = 5. * (patternUV * vec2(u_imageAspectRatio, 1.));
vec2 roughnessUv = 1.5 * (gl_FragCoord.xy - .5 * u_resolution) / u_pixelRatio;
float roughness = roughness(roughnessUv + vec2(1., 0.)) - roughness(roughnessUv - vec2(1., 0.));
vec2 crumplesUV = fract(patternUV * .02 / u_crumpleSize - u_seed) * 32.;
float crumples = u_crumples * (crumplesShape(crumplesUV + vec2(.05, 0.)) - crumplesShape(crumplesUV));
vec2 fiberUV = 2. / u_fiberSize * patternUV;
float fiber = fiberNoise(fiberUV, vec2(0.));
fiber = .5 * u_fiber * (fiber - 1.);
vec2 normal = vec2(0.);
vec2 normalImage = vec2(0.);
vec2 foldsUV = patternUV * .12;
foldsUV = rotate(foldsUV, 4. * u_seed);
vec2 w = folds(foldsUV);
foldsUV = rotate(foldsUV + .007 * cos(u_seed), .01 * sin(u_seed));
vec2 w2 = folds(foldsUV);
float drops = u_drops * drops(patternUV * 2.);
float fade = u_fade * fbm(.17 * patternUV + 10. * u_seed);
fade = clamp(8. * fade * fade * fade, 0., 1.);
w = mix(w, vec2(0.), fade);
w2 = mix(w2, vec2(0.), fade);
crumples = mix(crumples, 0., fade);
drops = mix(drops, 0., fade);
fiber *= mix(1., .5, fade);
roughness *= mix(1., .5, fade);
normal.xy += u_folds * min(5. * u_contrast, 1.) * 4. * max(vec2(0.), w + w2);
normalImage.xy += u_folds * 2. * w;
normal.xy += crumples;
normalImage.xy += 1.5 * crumples;
normal.xy += 3. * drops;
normalImage.xy += .2 * drops;
normal.xy += u_roughness * 1.5 * roughness;
normal.xy += fiber;
normalImage += u_roughness * .75 * roughness;
normalImage += .2 * fiber;
vec3 lightPos = vec3(1., 2., 1.);
float res = dot(normalize(vec3(normal, 9.5 - 9. * pow(u_contrast, .1))), normalize(lightPos));
vec3 fgColor = u_colorFront.rgb * u_colorFront.a;
float fgOpacity = u_colorFront.a;
vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
float bgOpacity = u_colorBack.a;
imageUV += .02 * normalImage;
float frame = getUvFrame(imageUV);
vec4 image = texture(u_image, imageUV);
image.rgb += .6 * pow(u_contrast, .4) * (res - .7);
frame *= image.a;
vec3 color = fgColor * res;
float opacity = fgOpacity * res;
color += bgColor * (1. - opacity);
opacity += bgOpacity * (1. - opacity);
opacity = mix(opacity, 1., frame);
color -= .007 * drops;
color.rgb = mix(color, image.rgb, frame);
fragColor = vec4(color, opacity);
}
`;
// node_modules/@paper-design/shaders/dist/get-shader-noise-texture.js
function getShaderNoiseTexture() {
if (typeof window === "undefined") {
return void 0;
}
const img = new Image();
img.src = noiseSrc;
return img;
}
var noiseSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAADAFBMVEUCAQMBAf7/AgMD/wID//7+/wT+A/4FAmYIAqIKnw7+//4EAisEAUgGBIYIewkFVhEJjAoFAuEFA8GWAv6T/gz+AzER/25z/wu1/w1nAggL/049BQUC/y39BrckAQQp/wr+AZYNOvx9AQkN/pELUvMFaAZTBAgIRgsO/7cJNQT+YgkLwRELIf5O/wlP/v79/q4IGAYLK4+kAQ1tAv4IdMpc/4xNMBF2/lQN2vTFAws9BLf9/3kJJgsMRF3+HwkLxfv9BVL8BHEN/9gMsg7cA/13/vv9OAqWA0sOofP9TAsIe/4FQqoF4Q/aAgsQwnKQAwa5BP0JW21NqgmY/f3Z/wkI7whGjAr7oAkLrGGf/JH8jg4zAj4R0Qr+xQ8VZv1Y/8O6//wfA/5bAT79/lQ1AGn8egkKdom0BgYOsfjtBAVDBoz9/zG0A238P/tsbQ/+A9rIig/HCEtvIgrM/1lwBWgIlmr62Q5qA5FndnEIXa+PthUMrqiRfw6SAodE/0cQm6UOirP5swuMCrEOjvo/dBVSA/79KvCgSBL9M1E/TwjUag/e//2WdPZ2TQ9ZMvfPxRD7aPpmOFqXSPu3pww5B/wR00wTgVf3y6dXW137ffv3c7GNj/icJG+4xvYQ61++CZOVll8p//uXzgyTKg6m/1L47w3cAY8EI1T7xvgKbkr7UsGBJPNsB7xL2wuvd5z3svmDmgipcGT8jez8oP0R6bNYuVpUxRn9LZVkqIijYxK7K/dZBtjH/71ZT/1myfz52fVm2WBfk0vxUFj+Vfv9/9plbfz3yl6VUl+flbNijrpfpfz5TZSGRKAI15X14pSt4vwQKMHOTQlKifz1sKW6A9u2A7R65waprffGcfeY/8iyUsFh3rn4lGERMUHJolveAs+PBdb5iZFuX8S8SH7Ekfe8Lwy0t5cLwsD3s2TzbHXa/478nLtNQ6NtstW15QvaKgr25FJm4vyXwFlPInIPId79dUr77fmr18BGdLHIS/mGx6dKw64L7v6k32XMJrWl8ELA3C70AAAgAElEQVR42gTBCTyUeQMA4P97zIx3ZjDvHGaMYQxjhhm33BGTY8h95sodkaNkXVGhKGdUri+SIxQ6nG36VUhS0rnZ6tsVfR2ibKlta7/d5wH7kMaTxlOVozEoHgU29/ayNC9YlrZdyVT+Lf/dAsDDc/xfzX+MLBa2LK23goK0aXhCxZ8qIAdXYj+c8zviDOtRkhEtRxNajHWLuCtdcfQqV2mgRlpDD6wJpKpBrGON27qa4nNeQOU8ViU0pZ2eCMN5mWO7bfR17Q9ItpsqgZJNJcJSq6cSWiV4q1zIDMmkqzAdpqT8gI5G3qm3YEyliPPG9kiwF7P99ghNn7zLs9EXFvFdLmlOdKBAp2ZyGTcI4JuBPYrWyGCYwgFwOhTmHeYC0zEDSp1iX3W71cqoW332M++OAYJUrEySVX0c5lzmDgLcAQ1yFVVOgQ5l+j1k6TEBidTUek7OF4T2kDYo2eVGwOrglKyGBXYyBrxFv9ptR16B+BJ0IFCsryJve0ZEuzNjLeEcw/0aK/kyku6JW0BiicnCBFptKAQRRNRrtmUV/YOn6GNMHXddsFf1YZCHMnFWgcyp2gnLOWTTBcVQVvM/FTgJAHl0NWHHzL0eqzuRXTDCEO03DoThV3kezhrtpNqKW0Bb3MSSAJMmmVnLEpexS8JrmYOr4KXz1cUmByty3N/sbEzBSP8tfGSCJ3caYDhymsPdGbwO4HAl/+PYDCZNf+H6kofkNk4N4Zn6NM4y1lJD7Tt2gyklnrR48dgbfHXgd9uzHvpamm3wKhcaLcawXWxL5T97dL7MeW3aZ7NDWksVZyZv8VQyjm94CDU7UjtbedqOCvB2DdE+wFC6a5JcEIgkKRJ8cfTGmW/2jMS5LEWWKiGY0BFaDNQ++2+sOifPMQ7CcHeFx+PPpcbzRoy4IKmVwHg/1842BwoGc2qlRVoNjCF59oXsrcBgVEP4u1GIX7jshIMqqPdbGTRJzMXcyyyiNG5fr5qFrUVntrktt4QdJugkr1kzNJCK1roWpTraix9JVMpZcsxGYsJlGiSyEgOFZzHy6YVlilnicmxUVkdX/PetzMBk92PNJNkIaLhmA30XPCrMuncWxOZK9kpLnqpYOOsLFFmaf2Mk8OH+BbwPH7HBX2KGI0Ns80gleH+Y6k0YZcF0sWgpoJA30BBbG59XaKyBHoxFtc2p9sFvyXqo2v2aRKN+1HLPshCibfZESAESYsLXmz3tT4wNMp0Wali+VPN93JIJaQ0AcXGrNMnSS0YASPcaNh32NhO0sWHKPhrNVpCBzyk4EWR/PnmKE+3s2cDO+YF6OddPNx7G4AIrZBPldw6tcss4bqzb6hBy6ccf3YaBSNRBFELueRFp7DXWNMFVAT9J1LNTntEyEI2gJS64oyKMKvSRrbpPQGE0rEEmHyqCl2oQravq51FwJXG0m/pPdRA6Xp3sSLdwGwNytaLg3g3VEE2eFESy/GijQPwmYPjwJT+bH/ax0dNT0NZAFQxyIqKzET00vUDuJ+T25QGCclaGZiJBxsjtz3YMZ0PPsq751h0ldwbZstMgHfnauk/7n1eZxEmYIPf5wPt0KJvg2V9bcYWGgua/Lvn/xG5q98tPLcGzHaac2+Cbs3niyPtGgfYgBT2OHgxvhGxzApoPxPoCOtUNCXX+ojW0ug7DOuyrOOG5GkWhaAzx6ZyGE8qbCPS1oxzPjcWSrG/ICNaNMKsra8bIlQVvmRQ/FY4WiHhnrVz/VfdOiOu6u66gG3NKogJ/0rGdbC+iPN1pbZ4HQAZODS+mC2z9dNBqSzd6mTQWKq+EI3fXgJQdqfqz6jY6Fbs4sWT/QkaLUOBnMhWRmSdrpTy769BcCql1UOmaqtFbDA9d7qEox8Lpa+TPXX+xm40jrB7EBK1lwu6IMud9xh7NBZCbq6PNN/QdTu0BVa2neF+s8b1dGns5tMGxQIP/+fiY60jZNp9n5D9MLm4NLWO2gXVG4xwDXHeHXMFEAITOVUGJRoBUwOV3miiTEPPzLrwDm74zFsW9zkfCASQvPi2RaF9qJ2HHWMJNxCHzDym6tNfXiEe28ZnjmHVGwlSvfgBo4afqcoTh4NNq7QQ1KrPJW+1uHEK1VvTghGa0DAePo8D6D1NCYgEPY239D/RQSUMxWJsAIi5KEp/3/9LH1wSTwl8/mfekwWyIhAwMPErzWxVSL7sFnFT1NqJ+Zb8hX4cqwyucXdUVkaqNeVL7abNtJV++aASn/d+Fw9qlVwplz4SqpVw5CBK7nq483nxbZ8p/8TtFwr8oD5uhq+lxfovd0x4+MHo1Wv14SJzqBo9Un1KCZ8NWfbA7jLeoMjnCcS8bjtKuxii0+0RPZlLS6NdhNKHeN2NSdCswa+K+aGFUTD9MLW9R7mhPT5i88TZvV5rWtuek07W/vBev9eJznPGkM8FrCZ53AB8+Ig7vKms99yRb5fpyoQssijTwz0i22O+HvjsjyGXpqseb4t4j6YW86PfJF2cnjmy8EKVF8sIomGUdVGBquOIDIlHsrgPkJEzw7KovqHB/kS+NPgs9nG9FkG1MJiA0GNwTyj5dRS0uiWTfSLf7jpL0ioLExajL/OJPkUbA6CIdKjpU6XrSY/6mE5Z1IDBoHX7tGx9fFkJZQPrPIW49pj9oUEykkiolzaein8mBh/C/0eAzYoFXHWJxYZWrv/ayPmcWsjfWyDy8ndnmPTldcJ05MaxOoIHWPcND2SOan44Wc1Oxyk59KHbiXwbrxB3qvAEA+Pd3zc3MkDFmxjG3K4ZxjHHfFXKNI691kyRLjmRCUmTQWnQo6XS8JNFBsTkqiRQpijalraTe1VPbpa1394/4PM+naUIl5jb9OQw4tXHsFyAoD/x8vmlYJu23hfowcTnJOXSMUdKum4IqKUd4HJguRiprd/Etw9K/NJ+UKE+T2v39ms2JRGhtNDxShw6kmZEdsr6fwVSzZUCgj/xK8CaD46MMqjtVmEE0DTPS7yo7so402lkAAr5A9TA8YbapYO+4tLHK+uBAqCsdrmkNB/tSNQxgrZRiBjhVSt904TQbBmEDW36UhZEwZN9TbWh1vtrLVYdkQKayJHgjO5aVftyaOhbtIVFjq0gImWcFJbXqPp+aGTaOzHzPptvWbli/tEz5BHs2WdU4y01sOWIdG+CPWbxSDnQ/KbYgddG1ggtPPUFvXeLdNH2EoslAveJl8GUVaLs6WWsoo3G2Q8KnvSkrNV13rJm4fF2jG2NKE3FMgjWPyCyVVZXDxk0WKQyzIcdGvhovfXwvS237WZN3PvX9Dh50V1CMuemc5AkPWBJzzlg8giqz/M3mICBajNsO3PSuByw3zV51gCTybHlfu/R+zXwVekhzN1C0gZCgqc3x8EUR5Mt8LndPRv3AbLnf2ZMLJ2TZBapthY8hSsIET5/vpH1T7/l1IKZl4pTp2eMVFT8J+1JyElnizM32GmBQTaTDJOwuvPCV3QDonD/6xjwgR6SA92MF+v+Xlo/BDyOZJpkM7QFh73uKxzX9hlDol/x5HVESyPM/HNyF6MwCg866UWXm9Jd2xsjrXyEKgjl11K41nEwzFzjyP0V9T87dStAustB/MkOwBaQoOCNG0+6dfSw2YIL2d+aAFbtewoPIATWJC+6il2nDFDx8Vlxg2a22oZG4My48gnrQEcDxOuE71wz51mkfvC3B8gjF04baNRpg6SGoHIAc+zB2Qqqn9yEzCXfpmpdN2kxdkiMQ/W/X7iT/RzkpBGvlGrx2Bs4pl3s8Akl3mRTsubk3x+CQH47r1ZNgECzf7IP0nV8lRUj1XqsW9+wNI0+oAx/lOGVsHcmalqdAqT/Rb+rp3wthEPxjXI6irxhTZc9U20OHSbYAJCX6MKHYW/P8X Line truncated
export {
ShaderMount,
getShaderNoiseTexture,
paperTextureFragmentShader
};
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>bake</title></head>
<body style="margin:0">
<div id="host" style="width:512px;height:512px"></div>
<script type="module">
import { ShaderMount, paperTextureFragmentShader, getShaderNoiseTexture } from "/_lab/paper-bundle.js";
try {
const host = document.getElementById("host");
const t0 = performance.now();
const noise = getShaderNoiseTexture();
await noise.decode();
const mount = new ShaderMount(
host,
paperTextureFragmentShader,
{
u_colorBack: [1, 1, 1, 1],
u_colorFront: [0, 0, 0, 1],
u_contrast: 0.3,
u_roughness: 0.4,
u_fiber: 0.3,
u_fiberSize: 0.2,
u_crumples: 0.3,
u_crumpleSize: 0.35,
u_folds: 0.65,
u_foldCount: 5,
u_drops: 0.2,
u_fade: 0,
u_seed: 5.8,
u_scale: 0.6,
u_noiseTexture: noise,
},
{ antialias: false },
0,
0,
1,
);
// ждём, пока текстура шума реально загрузится и кадр отрендерится
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = host.querySelector("canvas");
// переливаем в свой canvas, чтобы toDataURL дал полный кадр
const out = document.createElement("canvas");
out.width = canvas.width;
out.height = canvas.height;
out.id = "out";
out.getContext("2d").drawImage(canvas, 0, 0);
document.body.appendChild(out);
window.__T = Math.round(performance.now() - t0);
window.__READY = true;
} catch (e) {
window.__ERR = e.message;
}
</script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
// Живой прототип Paper Shaders на стенде _lab (без правки прод-кода).
// Пакет скопирован локально в /vendor/paper-shaders (zero-dep, ESM).
import {
ShaderMount,
meshGradientFragmentShader,
getShaderColorFromString,
defaultObjectSizing,
ShaderFitOptions,
} from '/vendor/paper-shaders/index.js';
// Цвета по бренду (как у .contact-card / .neu-card--shader)
const BRAND = {
tg: ['#0088cc', '#00bcd4', '#0a66c2'],
vk: ['#0077ff', '#4a90ff', '#0033aa'],
ya: ['#f8604a', '#ffae00', '#ff3d00'],
email: ['#7c5cff', '#b06cff', '#5a3fd6'],
demo: ['#7c5cff', '#00c2c7', '#ff5c8a', '#ffb347'],
};
// Официальная конвертация hex/HSL/RGB -> [r,g,b,a] (0..1), как ожидает u_colors (vec4[]).
const c4 = (arr) => arr.map(getShaderColorFromString);
// Sizing uniforms обязательны (vertex-шейдер делит на u_scale — при 0 будет NaN
// и весь шейдер чёрный). Берём дефолты из пакета, как в React-обёртке.
const sizing = () => ({
u_fit: ShaderFitOptions[defaultObjectSizing.fit],
u_scale: defaultObjectSizing.scale,
u_rotation: defaultObjectSizing.rotation,
u_offsetX: defaultObjectSizing.offsetX,
u_offsetY: defaultObjectSizing.offsetY,
u_originX: defaultObjectSizing.originX,
u_originY: defaultObjectSizing.originY,
u_worldWidth: defaultObjectSizing.worldWidth,
u_worldHeight: defaultObjectSizing.worldHeight,
});
// Единственный используемый шейдер: mesh gradient, анимированный.
const MESH = {
frag: meshGradientFragmentShader,
speed: 1.0,
uniforms: (c) => ({
...sizing(),
u_colors: c4(c), u_colorsCount: c.length,
u_distortion: 1.2, u_swirl: 0.8, u_grainMixer: 0.08, u_grainOverlay: 0.04,
}),
};
const mounts = new WeakMap();
function mountShader(host, colors) {
const wrap = host.querySelector('.card-shader');
if (!wrap) return;
if (mounts.has(host)) { try { mounts.get(host).dispose(); } catch {} mounts.delete(host); }
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
try {
const m = new ShaderMount(wrap, MESH.frag, MESH.uniforms(colors),
{ preserveDrawingBuffer: true, antialias: true }, MESH.speed);
// отключаем IntersectionObserver: иначе вне вьюпорта ставит currentSpeed=0
// и рендер замирает (у пользователя карточка внизу страницы -> пауза).
try { m.intersectionObserver?.disconnect(); } catch {}
mounts.set(host, m);
} catch (e) {
console.error('ShaderMount failed', e);
}
}
function mountAll(root = document) {
// любые карточки с шейдером: у .card-shader есть data-shader (бренд),
// сама карточка — его родитель (contact-card, neu-card--shader, shader-cell...)
root.querySelectorAll('.card-shader[data-shader]').forEach((wrap) => {
const host = wrap.parentElement;
const brand = wrap.dataset.shader;
mountShader(host, BRAND[brand] || BRAND.demo);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountAll);
} else {
mountAll();
}
window.__mountLabShaders = mountAll;
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium" data-font="geologica">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Стенд: &lt;название элемента&gt;</title>
<!--
Базовый шаблон песочницы _lab.
Правила:
- Сборка НЕ нужна. Правишь public/css/main.css или этот файл → страница сама
перезагрузится (live-reload сервера npm run serve).
- Стили компонента живут в public/css/main.css. Сюда пишешь только обвязку стенда.
- Скрипт темы (lab.js) переиспользуется как есть — даёт переключатель темы/радиуса.
- Чтобы добавить новый стенд: скопируй этот файл в public/_lab/<имя>.html,
поправь заголовок и секции. Готовый элемент потом встрой в src/pages/**.
-->
<link rel="stylesheet" href="/css/main.css" />
<link rel="stylesheet" href="/_lab/lab.css" />
</head>
<body>
<div class="page-mesh-bg" aria-hidden="true"></div>
<main class="lab">
<header class="lab__head">
<h1 class="lab__h1">&lt;название элемента&gt;</h1>
<p class="lab__sub">
Стенд для настройки вида. Сборка не нужна: правим <code>public/css/main.css</code> →
страница перезагрузится сама. Переключатель темы в правом нижнем углу.
</p>
<nav class="lab__nav" aria-label="Разделы стенда">
<a href="#sec-demo">Демо</a>
<a href="#sec-tokens">Токены</a>
</nav>
</header>
<!-- Копируй эту секцию под каждый аспект настройки -->
<section class="lab__sec" id="sec-demo">
<h2 class="lab__h2">Демо</h2>
<p class="lab__note">Опиши, что смотрим. Здесь — сам элемент во всех состояниях.</p>
<div class="lab__row lab__row--wrap lab__depth">
<!-- Пример карточки из готового компонента neu-card: -->
<article class="neu-card">
<span class="neu-card__title">Состояние</span>
<span class="neu-card__meta">Подпись</span>
</article>
</div>
</section>
<section class="lab__sec" id="sec-tokens">
<h2 class="lab__h2">Токены</h2>
<p class="lab__note">Числа, которыми управляется вид. Значение по умолчанию — в скобках.</p>
<div class="lab__tokens">
<dl class="lab__token">
<dt><code>--neu-bg</code> <span>--bg-elevated</span></dt>
<dd>Нейтральная заливка карточки.</dd>
</dl>
</div>
</section>
</main>
<div class="lab-switch" role="group" aria-label="Настройки стенда">
<button type="button" class="lab-switch__btn" data-lab-theme="light">Светлая</button>
<button type="button" class="lab-switch__btn" data-lab-theme="dark">Тёмная</button>
<span class="lab-switch__sep" aria-hidden="true"></span>
<button type="button" class="lab-switch__btn" data-lab-radius="soft">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="medium">R·</button>
<button type="button" class="lab-switch__btn" data-lab-radius="strong">R·</button>
</div>
<script type="module" src="/_lab/lab.js"></script>
<script type="module" src="/_lab/live-reload.js"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
# ai.txt — mamadaevv.site
# Описание сайта для ИИ-агентов и ассистентов.
# Формат: https://github.com/dvetter/ai.txt
name: Портфолио Николая Мамадаева
description: >
Личный сайт продуктового дизайнера Николая Мамадаева. Кейсы UX/UI-проектов,
интерфейсов и дизайн-систем, резюме и контакты. Сайт доступен на русском и
английском языках.
author: Николай Мамадаев
contact: https://t.me/mamadaevv
canonical: https://mamadaevv.site/
sitemap: https://mamadaevv.site/sitemap.xml
# Разрешения для ИИ-агентов
policy: allow
allow: /
disallow: /_lab/
# Разрешённые сценарии использования
use: index
use: summarize
use: cite
use: reference
# Требование атрибуции
attribution: required
attribution_url: https://mamadaevv.site/
# Языки
languages: ru, en
default_language: ru
# Структура сайта
sections:
- path: /
title: Главная
description: Обо мне, портфолио и активность.
- path: /resume/
title: Резюме
description: Опыт работы, навыки и контакты.
- path: /cases/
title: Кейсы
description: Разборы UX/UI-проектов и дизайн-систем.
- path: /cookie-policy/
title: Политика cookie
+139 -139
View File
@@ -1,139 +1,139 @@
/* geologica-cyrillic-ext-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-cyrillic-ext-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* geologica-cyrillic-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-cyrillic-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* geologica-greek-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-greek-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* geologica-vietnamese-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-vietnamese-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* geologica-latin-ext-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-latin-ext-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* geologica-latin-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-latin-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* pt-sans-cyrillic-ext-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* pt-sans-cyrillic-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* pt-sans-latin-ext-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* pt-sans-latin-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* pt-sans-cyrillic-ext-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* pt-sans-cyrillic-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* pt-sans-latin-ext-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* pt-sans-latin-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* geologica-cyrillic-ext-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-cyrillic-ext-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* geologica-cyrillic-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-cyrillic-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* geologica-greek-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-greek-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* geologica-vietnamese-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-vietnamese-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* geologica-latin-ext-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-latin-ext-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* geologica-latin-wght-normal */
@font-face {
font-family: 'Geologica';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(./files/geologica-latin-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* pt-sans-cyrillic-ext-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* pt-sans-cyrillic-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* pt-sans-latin-ext-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* pt-sans-latin-400-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-sans-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* pt-sans-cyrillic-ext-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* pt-sans-cyrillic-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* pt-sans-latin-ext-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* pt-sans-latin-700-normal */
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/pt-sans-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
+139 -139
View File
@@ -1,139 +1,139 @@
/* alice-cyrillic-ext-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* alice-cyrillic-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* alice-latin-ext-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* alice-latin-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* merriweather-cyrillic-ext-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* merriweather-cyrillic-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* merriweather-vietnamese-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* merriweather-latin-ext-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* merriweather-latin-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* merriweather-cyrillic-ext-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* merriweather-cyrillic-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* merriweather-vietnamese-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* merriweather-latin-ext-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* merriweather-latin-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* alice-cyrillic-ext-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* alice-cyrillic-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* alice-latin-ext-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* alice-latin-400-normal */
@font-face {
font-family: 'Alice';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/alice-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* merriweather-cyrillic-ext-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* merriweather-cyrillic-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* merriweather-vietnamese-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* merriweather-latin-ext-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* merriweather-latin-400-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/merriweather-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* merriweather-cyrillic-ext-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* merriweather-cyrillic-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* merriweather-vietnamese-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* merriweather-latin-ext-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* merriweather-latin-700-normal */
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/merriweather-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
+319 -319
View File
@@ -1,319 +1,319 @@
/* pt-mono-cyrillic-ext-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* pt-mono-cyrillic-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* pt-mono-latin-ext-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* pt-mono-latin-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-greek-ext-400-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-greek-ext-500-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-greek-500-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-cyrillic-ext-600-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-cyrillic-600-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-greek-ext-600-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-greek-600-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-vietnamese-600-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-greek-ext-700-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-greek-700-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* pt-mono-cyrillic-ext-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* pt-mono-cyrillic-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* pt-mono-latin-ext-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* pt-mono-latin-400-normal */
@font-face {
font-family: 'PT Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/pt-mono-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-greek-ext-400-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-400-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(./files/source-code-pro-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-greek-ext-500-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-greek-500-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-500-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(./files/source-code-pro-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-cyrillic-ext-600-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-cyrillic-600-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-greek-ext-600-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-greek-600-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-vietnamese-600-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-600-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(./files/source-code-pro-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* source-code-pro-cyrillic-ext-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* source-code-pro-cyrillic-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* source-code-pro-greek-ext-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-greek-ext-700-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* source-code-pro-greek-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-greek-700-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* source-code-pro-vietnamese-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* source-code-pro-latin-ext-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* source-code-pro-latin-700-normal */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(./files/source-code-pro-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9V2H18V9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 18H4C2.89543 18 2 17.1046 2 16V11C2 9.89543 2.89543 9 4 9H20C21.1046 9 22 9.89543 22 11V16C22 17.1046 21.1046 18 20 18H18" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 15H18V22H6V15Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

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

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

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