`) по 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` на ``.
+- Заглушка при загрузке: `.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-агентов |
diff --git a/IDEA.md b/IDEA.md
new file mode 100644
index 0000000..7f114c8
--- /dev/null
+++ b/IDEA.md
@@ -0,0 +1 @@
+Мой сайт портфолио
diff --git a/_fab_ds.png b/_fab_ds.png
new file mode 100644
index 0000000..98a3963
Binary files /dev/null and b/_fab_ds.png differ
diff --git a/_fab_lunacy.png b/_fab_lunacy.png
new file mode 100644
index 0000000..65b3b80
Binary files /dev/null and b/_fab_lunacy.png differ
diff --git a/_menu2.mjs b/_menu2.mjs
new file mode 100644
index 0000000..ef7677c
--- /dev/null
+++ b/_menu2.mjs
@@ -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();
diff --git a/allsh.mjs b/allsh.mjs
new file mode 100644
index 0000000..dd12e25
--- /dev/null
+++ b/allsh.mjs
@@ -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();
diff --git a/anim.mjs b/anim.mjs
new file mode 100644
index 0000000..d26f97a
--- /dev/null
+++ b/anim.mjs
@@ -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();
diff --git a/anim2.mjs b/anim2.mjs
new file mode 100644
index 0000000..07b83ec
--- /dev/null
+++ b/anim2.mjs
@@ -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();
diff --git a/anim3.mjs b/anim3.mjs
new file mode 100644
index 0000000..d7bbb2b
--- /dev/null
+++ b/anim3.mjs
@@ -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); xsetTimeout(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();
diff --git a/animlong.mjs b/animlong.mjs
new file mode 100644
index 0000000..d8e68d8
--- /dev/null
+++ b/animlong.mjs
@@ -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;ysetTimeout(r,3000)); const c=await hash();
+console.log('hash1', a); console.log('hash2', c); console.log('ANIMATED:', a!==c);
+await b.close();
diff --git a/basicshader.mjs b/basicshader.mjs
new file mode 100644
index 0000000..8c7dc5c
--- /dev/null
+++ b/basicshader.mjs
@@ -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();
diff --git a/bright.mjs b/bright.mjs
new file mode 100644
index 0000000..b22f7f3
--- /dev/null
+++ b/bright.mjs
@@ -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);y250) brightPx++; n++; }
+console.log('max channel values in demo card:', maxR, maxG, maxB, 'bright pixels:', brightPx, '/', n);
+await b.close();
diff --git a/checkshadows.mjs b/checkshadows.mjs
new file mode 100644
index 0000000..44aaf7a
--- /dev/null
+++ b/checkshadows.mjs
@@ -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();
diff --git a/chk2.mjs b/chk2.mjs
new file mode 100644
index 0000000..a78b186
--- /dev/null
+++ b/chk2.mjs
@@ -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();
diff --git a/chk3.mjs b/chk3.mjs
new file mode 100644
index 0000000..3b841be
--- /dev/null
+++ b/chk3.mjs
@@ -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();
diff --git a/clean.mjs b/clean.mjs
new file mode 100644
index 0000000..3facf85
--- /dev/null
+++ b/clean.mjs
@@ -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();
diff --git a/cmp.mjs b/cmp.mjs
new file mode 100644
index 0000000..39b8e6c
--- /dev/null
+++ b/cmp.mjs
@@ -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();
diff --git a/cmp2.mjs b/cmp2.mjs
new file mode 100644
index 0000000..a7e8f12
--- /dev/null
+++ b/cmp2.mjs
@@ -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();
diff --git a/cmp3.mjs b/cmp3.mjs
new file mode 100644
index 0000000..cfbc9df
--- /dev/null
+++ b/cmp3.mjs
@@ -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();
diff --git a/cmpf.mjs b/cmpf.mjs
new file mode 100644
index 0000000..7aa2351
--- /dev/null
+++ b/cmpf.mjs
@@ -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();
diff --git a/console.mjs b/console.mjs
new file mode 100644
index 0000000..e544346
--- /dev/null
+++ b/console.mjs
@@ -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();
diff --git a/correct.mjs b/correct.mjs
new file mode 100644
index 0000000..83adb3f
--- /dev/null
+++ b/correct.mjs
@@ -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;i120)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();
diff --git a/ctor.mjs b/ctor.mjs
new file mode 100644
index 0000000..05abcc2
--- /dev/null
+++ b/ctor.mjs
@@ -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;i120)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();
diff --git a/demochk.mjs b/demochk.mjs
new file mode 100644
index 0000000..51c17a5
--- /dev/null
+++ b/demochk.mjs
@@ -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>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();
diff --git a/democrop.mjs b/democrop.mjs
new file mode 100644
index 0000000..2485ec6
--- /dev/null
+++ b/democrop.mjs
@@ -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>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();
diff --git a/demodata.mjs b/demodata.mjs
new file mode 100644
index 0000000..2c14e87
--- /dev/null
+++ b/demodata.mjs
@@ -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>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();
diff --git a/demofull.mjs b/demofull.mjs
new file mode 100644
index 0000000..36f5522
--- /dev/null
+++ b/demofull.mjs
@@ -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>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();
diff --git a/demolast.mjs b/demolast.mjs
new file mode 100644
index 0000000..3745aab
--- /dev/null
+++ b/demolast.mjs
@@ -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>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();
diff --git a/demook.mjs b/demook.mjs
new file mode 100644
index 0000000..20a0423
--- /dev/null
+++ b/demook.mjs
@@ -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>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();
diff --git a/demopic.mjs b/demopic.mjs
new file mode 100644
index 0000000..276700a
--- /dev/null
+++ b/demopic.mjs
@@ -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();
diff --git a/demovis.mjs b/demovis.mjs
new file mode 100644
index 0000000..573a639
--- /dev/null
+++ b/demovis.mjs
@@ -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();
diff --git a/deploy.sh b/deploy.sh
deleted file mode 100644
index 8d0edb5..0000000
--- a/deploy.sh
+++ /dev/null
@@ -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"
diff --git a/diag.mjs b/diag.mjs
new file mode 100644
index 0000000..ac131bb
--- /dev/null
+++ b/diag.mjs
@@ -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();
diff --git a/doccheck.mjs b/doccheck.mjs
new file mode 100644
index 0000000..932d6c2
--- /dev/null
+++ b/doccheck.mjs
@@ -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;i120) 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();
diff --git a/docs/case-page-blocks.md b/docs/case-page-blocks.md
index 3c7237b..595c550 100644
--- a/docs/case-page-blocks.md
+++ b/docs/case-page-blocks.md
@@ -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 на точках;
+- тач/мышь-свайп (только горизонтальный);
+- ленивая подгрузка соседних кадров: у `` можно задать `data-src` (временный `src` — прозрачный GIF), скрипт подставит при подходе, как у галереи; пока есть `data-src`, клик по viewer не открывается;
+- клик по активному кадру открывает общий viewer кейса (`data-image-viewer`).
+
+**Ширина слайдера подстраивается под ширину картинок** (высота — автоматически по контенту). Модификаторы как у статичных `.case-media`:
+
+- базовый (без модификатора) / `.case-media--content` — ширина = `--slider-natural-w` (по умолчанию 100% колонки контента). Задайте на `
` 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
+
+
+ Как добавить стенд: скопируй /_lab/template.html в
+ /_lab/<имя>.html, поправь заголовок и секции. Стили компонента — в
+ public/css/main.css, обвязка стенда — в /_lab/lab.css.
+ Переключатель темы подключается автоматически через lab.js.
+
+ Стенд для настройки вида. Сборка не нужна: правим public/css/main.css →
+ страница перезагружается сама (live-reload). Переключатель темы в правом нижнем углу.
+
+
+
+
+
+
+
Финальный вид
+
+ Принятые значения: сила объёма 0.5, блик 0.2, без текстуры.
+ Обычная неуморфная карточка на нейтральном фоне.
+
+
+ Только мета
+
+
+
+ Только заголовок
+
+
+
+ Eyebrow
+ Полный набор
+ Подпись под заголовком
+
+
+
+ Длинный заголовок в несколько строк
+
+ И подпись тоже длинная, чтобы проверить, как карточка растёт по высоте
+
+
+
+
+
+ С иконкой
+ Иконка сверху
+
+
+
+
+ С обложкой
+ Картинка на всю ширину
+
+
+
+ Акцентная
+ --accent
+
+
+
+
+
+
+
Размеры
+
+ Базовый размер задаётся --neu-min-w / --neu-min-h, дальше
+ карточка растёт от содержимого. В сетке — .neu-card--fluid.
+
+ Рядом друг с другом тени соседних карточек накладываются. Здесь видно, не слипаются ли.
+
+
+
Три в ряд, gap 1rem
+
+
+ Проект один
+ Продуктовый дизайн
+
+
+ Проект два
+ Исследование
+
+
+ Проект три
+ Дизайн-система
+
+
+
+
Смешанный рост
+
Разная высота содержимого — проверка на растяжение по сетке.
+
+
+ Короткая
+
+
+ Средняя по объёму
+
+ Здесь подпись занимает две строки, и карточка должна потянуться выше соседней
+
+
+
+ С обложкой
+ Ещё выше
+
+
+
+
+
+
+
Карточка в карточке
+
+ Нижняя карточка больше и включает в себя верхние. Оценка того, как объём читается, когда
+ карточка лежит поверх той же самой поверхности, а не на фоне страницы. Это худший
+ случай для неуморфизма: обе поверхности одинаковые по цвету.
+
+
+
+
+ Внешняя
+ Карточка-контейнер
+ Поверхность второго уровня, вложенные карточки внутри
+
+
+
+ Вложенная
+ Тот же --neu-bg
+
+
+ Вложенная
+ Тот же --neu-bg
+
+
+ Вложенная
+ Тот же --neu-bg
+
+
+
+
+
+
+
+
+
+
+
То же в светлой теме
+
+ Внутренние блики настроены раздельно для тёмной и светлой темы — здесь видна разница.
+
+
+
+
+
+
Светлая тема · тень
+
+ На светлом фоне чистый чёрный 0.25 выглядит грязно. Варианты: цвет тени смещён в
+ холодный тёмно-серый, альфа внешних теней снижена через --neu-drop-mult.
+ Эта секция принудительно светлая.
+
+
+
+
+ Текущий
+ 47,53,75 · drop 0.5
+
+
как в main.css
+
+
+
+ Мягче
+ 70,75,95 · drop 0.4
+
+
светлее тень
+
+
+
+ Глубже
+ 30,35,55 · drop 0.35
+
+
темнее, но меньше альфа
+
+
+
+ Чёрный
+ 0,0,0 · drop 0.5
+
+
было грязно
+
+
+
+
+
+
+
Контактные карточки
+
+ Оригинальная .contact-card с сайта, теперь с неуморфной основой
+ (объём как у .neu-card). Бренд-цвета через модификаторы
+ --brand-tg / --brand-vk / --brand-ya / --brand-email.
+ Цветное наложение — радиальный градиент ::before из --contact-brand.
+
+ Обычная неуморфная карточка (тени снаружи, блик из угла) с живым шейдером
+ внутри, под бликом. Слева — чистая neu-card для сравнения,
+ справа — та же карточка с анимированным mesh-слоем. Объём и блик сохранены,
+ шейдер только подсвечивает поверхность.
+
+
+
Сетка 3 × 2
+
+
+
+ Проект
+ Обычная
+ только тени и блик
+
+
+
+ Проект
+ Шейдер · demo
+ mesh под бликом
+
+
+
+ Проект
+ Шейдер · email
+ mesh под бликом
+
+
+
+
+
+ Проект
+ Шейдер · tg
+ mesh под бликом
+
+
+
+ Проект
+ Шейдер · vk
+ mesh под бликом
+
+
+
+ Проект
+ Шейдер · ya
+ mesh под бликом
+
+
+
+
Одиночная крупная
+
+
+
+ Кейс
+ Neu + shader
+ объём снаружи, анимация внутри
+
+
+
+ Кейс
+ Static + shader
+ блик и анимация, без hover-отклика
+
+
+
+
+
+
+
Mesh gradient
+
+ Единственный используемый шейдер — анимированный mesh gradient (цветные пятна
+ плавно перетекают). Ниже — карточка-пример только с ним. Контактные и матричные
+ карточки выше используют тот же шейдер с брендовыми цветами.
+
+
+
Пример · mesh gradient
+
+
+
+ mesh gradient
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/_lab/noise-test.html b/public/_lab/noise-test.html
new file mode 100644
index 0000000..d44965c
--- /dev/null
+++ b/public/_lab/noise-test.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/public/_lab/paper-bundle.js b/public/_lab/paper-bundle.js
new file mode 100644
index 0000000..6d8c788
--- /dev/null
+++ b/public/_lab/paper-bundle.js
@@ -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,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";
+export {
+ ShaderMount,
+ getShaderNoiseTexture,
+ paperTextureFragmentShader
+};
diff --git a/public/_lab/render.html b/public/_lab/render.html
new file mode 100644
index 0000000..43e6607
--- /dev/null
+++ b/public/_lab/render.html
@@ -0,0 +1,56 @@
+
+
+ bake
+
+
+
+
+
diff --git a/public/_lab/shader-demo.js b/public/_lab/shader-demo.js
new file mode 100644
index 0000000..46bcb50
--- /dev/null
+++ b/public/_lab/shader-demo.js
@@ -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;
diff --git a/public/_lab/template.html b/public/_lab/template.html
new file mode 100644
index 0000000..313ac00
--- /dev/null
+++ b/public/_lab/template.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+ Стенд: <название элемента>
+
+
+
+
+
+
+
+
+
+
<название элемента>
+
+ Стенд для настройки вида. Сборка не нужна: правим public/css/main.css →
+ страница перезагрузится сама. Переключатель темы в правом нижнем углу.
+
+
+
+
+
+
+
Демо
+
Опиши, что смотрим. Здесь — сам элемент во всех состояниях.
+
+
+
+ Состояние
+ Подпись
+
+
+
+
+
+
Токены
+
Числа, которыми управляется вид. Значение по умолчанию — в скобках.
Упростил настройку Primo RPA AI Server: клиенты настраивают ИИ-сервис без команды внедрения
+
+ Платформа интеллектуальной обработки документов
+ и оркестрации ИИ-агентов. Обращения к команде внедрения упали на 40%, настройка первого проекта ужалась с ~3 дней до ~4 часов.
+
+
+
+
+
+
+
+
Продукт
+
AI Server. Нейросети во внутреннем контуре компании, настраивается через веб-интерфейс
+
Роль
+
UX/UI-дизайнер (единственный на проекте)
+
Задача
+
Снизить порог входа, чтобы клиент настраивал сервис сам
+
Стек
+
Ant Design + React, стилизация Hope UI
+
Формат
+
Редизайн живого продукта, релизы не останавливались
+
+
+
+
С настройкой справлялись только эксперты. Это тормозило продажи
+
+ AI Server разворачивается на внутренних серверах клиентов. Продукт активно тестировали, и обратная связь показала неприятное: настроить его могли только эксперты и команда внедрения Primo RPA. Каждый новый клиент съедал часы техподдержки. Продукт терял привлекательность и плохо масштабировался.
+
+
+
+ Начал со знакомства с командой и экспертами по настройке. Разделил сценарии на главные и вспомогательные, собрал визуализацию того, как ведёт себя пользователь, и вместе с разработкой искал, где интерфейс можно упростить. Я уже работал с ИИ и похожими веб-платформами, поэтому быстро разобрался в домене и говорил с разработкой на одном языке.
+
+
+
+
+
Ограничения
+
+
Продуктом уже пользовались клиенты. Остановить разработку и пересобрать интерфейс целиком было нельзя.
+
Изменения приходилось вписывать в план релизов, отдельного «редизайн-спринта» никто не выделял.
+
Жёсткий стек: Ant Design под React со стилизацией под Hope UI. Новые компоненты рисовать только в крайнем случае, сначала выжимаем всё из готовой библиотеки.
+
+
+
+
+
Навигация путала: разделы проекта смешивались с главным меню
+
+ В сервисе стояла привычная для админок схема: слева боковое меню, справа контент. Пространство расходовалось расточительно, а при открытии проекта навигация по его разделам оказывалась вперемешку с главной. Пользователь терялся.
+
+
+
+
+ Главный экран AI Server с компактной верхней навигацией — список проектов занимает почти всю ширину экрана
+
+
+ Перенёс главную навигацию наверх и ужал её. Контенту стало заметно просторнее, а схема осталась привычной. У проектов появилась собственная навигация, которая с главной не пересекается.
+
+
+
+ Внутренняя навигация NLP-проекта — горизонтальные пункты-шаги под главным меню
+
+
+
+ Внутренняя навигация OCR-проекта — тот же паттерн со своим набором разделов
+
+
+
+
+ Крупный план вторичной навигации — пункты в форме стрелок, ведущих слева направо
+
+
+
+
+
Редактор датасета: экран, где пользователи живут
+
+ Здесь проводят больше всего времени. Разметка полей на сотнях изображений — работа монотонная, но именно от неё зависит качество данных для обучения модели. Прежний редактор выбивался из общего стиля, выглядел перегруженно и путал в работе с полями.
+
+
+ Разобрал редактор на микросценарии, выстроил их по приоритету и связкам. Вместе с командой спроектировали поведение плавнее и понятнее, заложили место под будущий функционал, дизайн привели к общей стилистике сервиса.
+
+
+
+ Редактор разметки датасета — размечаемый документ в центре, панели полей и инструментов по бокам
+
+
+ Страница стала симметричной. Почти всё пространство уходит на размечаемое изображение, действия сгруппированы по панелям: нужная функция ищется быстро.
+
+
+
+ Схематичный каркас редактора — центральная рабочая область и боковые панели, режим по умолчанию
+
+
+ Для продвинутых пользователей добавил режим «Во весь экран». Боковые панели сворачиваются, работа идёт через горячие клавиши и нижнюю панель. На небольших мониторах это спасает.
+
+
+
+ Полноэкранный режим редактора с раскрытыми боковыми панелями
+
+
+
+ Полноэкранный режим без панелей — на экране только размечаемый документ и нижняя панель инструментов
+
+
+
+ Четыре варианта каркаса полноэкранного режима — обе панели, без панелей, только левая, только правая
+
+
+
+ Нижняя панель инструментов — переключение между изображениями и быстрые действия разметки
+
+
+
+
+
Десятки полей на одном скане, и место под документ не страдает
+
+ Смежная задача: настроить уникальные параметры полей датасета. Большая область нужна под скан документа, полей десятки, у каждого свои типы и зависимые параметры.
+
+
+
+
+
+ Прототип сопоставления полей — пользователь выбирает поле на скане документа и настраивает его параметры в компактной панели
+
+
+ Интерфейс вышел компактным. Механика не считывается с первого взгляда, зато после короткого обучения работать в нём быстро и комфортно.
+
+
+
+
+
Карточки обучения: паттерн, который разошёлся по всему продукту
+
+ После разметки датасета модель дообучают (fine-tuning). Разобрали все действия пользователя, перебрали несколько компоновок страницы. Остановились на ленте из карточек.
+
+
+
+ Раздел обучения моделей — вертикальная лента карточек, сверху карточка настройки нового обучения
+
+
+ В начале ленты карточка настройки: результат разметки активного датасета, параметры, запуск fine-tuning.
+
+
+
+ Карточка настройки обучения — матрица качества датасета и блок параметров запуска
+
+
+
+ Под каждый запущенный процесс появляется своя карточка. Можно завести несколько процессов с разными параметрами и сравнить, какая конфигурация отработала лучше.
+
+
+
+ Карточка запущенного процесса обучения — статус, прогресс и метрики, разнесённые по логическим блокам
+
+
+
+ Каркас карточки процесса обучения — схема расположения информационных блоков и действий
+
+
+ Карточки сворачиваются, в шапке остаются название и статус.
+
+
+
+ Свёрнутая карточка обучения — одна строка с названием и статусом процесса
+
+
+ Паттерны из этих карточек потом разошлись по другим разделам продукта. Единый стиль стало проще держать.
+
+
+
+
+
Долгие процессы — в соседнюю вкладку
+
+ Часть задач идёт параллельно с остальной работой. Для таких сценариев модальное окно открывается на отдельной странице.
+
+
+
+
+ Импорт датасета в модальном окне на отдельной странице — форма загрузки на весь экран
+
+
+ На странице проекта в это время висит заглушка с кнопкой перехода к процессу.
+
+
+
+ Заглушка активного импорта внутри проекта — индикатор процесса и кнопка перехода
+
+
+
+
+
Что я решил не делать, и почему
+
+
Не стал блокировать кнопки на отдельных страницах во время активных процессов. Модалка на отдельной странице технически проще, а прогресс виден в соседней вкладке.
+
Не рисовал новые компоненты, кроме крайних случаев. Готовая библиотека Ant Design ускоряла разработку и держала интерфейс консистентным.
+
Не убрал боковые панели редактора совсем, а сделал сворачиваемыми. Новичкам нужны подсказки на экране, продвинутым — максимум места под документ.
+
Не заводил отдельную дизайн-систему. В продукте уже был фреймворк, в разработку передавал ссылки на готовые юзерфлоу в Figma.
+
+
Передача в разработку
+
+ Раз в неделю обсуждали дизайн с командой: список задач, приоритеты, сроки под планы релизов. С верстальщиком уточнял детали макетов тет-а-тет и давал фидбэк после вёрстки, чтобы результат не разошёлся с задуманным поведением.
+
+
+
+
+
Результат
+
+
+
Обращения к команде внедрения на этапе настройки сократились на 40%. Базовую настройку клиенты проходят сами.
+
7 из 10 новых клиентов настроили первый проект без техподдержки. До редизайна — 1 из 10.
+
Настройка первого проекта: с ~3 дней до ~4 часов.
+
Переработано 30+ экранов: навигация, редактор датасета, обучение моделей, импорт данных.
+
Заложены паттерны, на которых функционал наращивается без больших переделок.
+
В компанию наняли второго дизайнера. Я ввёл её в курс дела и курировал результат, после адаптации проект целиком перешёл под её ответственность.
+
+
+
+
+
Выводы
+
+
Жёсткий фреймворк оказался не ограничением, а ускорителем: готовая библиотека компонентов позволила думать о сценариях, а не о пикселях.
+
Самые ценные инсайты дали эксперты по настройке. В следующий раз вовлёк бы команду внедрения ещё раньше.
+
Передача проекта новому дизайнеру подтвердила: заложенные паттерны работают, их можно объяснить и поддерживать без автора.
+ Канбан-вид для Obsidian Bases, который не трогает ваши заметки
+
+
+ Card Columns: заметки как карточки по колонкам,
+ отбор по тегам И/ИЛИ за два клика
+
+
+
+
+
+
РольПродуктовый дизайн и разработка
+
ПлатформаObsidian Bases
+
СтатусРабочий плагин, open source (MIT)
+
КомандаСоло, пет-проект
+
СрокЧерновой ответ, заменить
+
+
+
+ Вопрос к автору
+
+ Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных?
+
+
+ Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу
+ собственной работы. Заменить на реальное.
+
+
+
+
+ В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а
+ в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он
+ плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их
+ раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns.
+
+
+
+
Контекст
+
+ Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь
+ заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть
+ встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет
+ нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель
+ Bases наравне со штатными.
+
+
+ Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что
+ выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика
+ выборки остаётся на Bases, а моя задача, только представление и навигация по нему.
+
+
+
+
+
+
Почему не готовое
+
+ Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо:
+
+
+ Штатный вид карточек Bases. Рисует ровный поток карточек без группировки.
+ Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки.
+ Отпал первым.
+
+
+ Канбан-плагины из комьюнити. Большинство тянет за собой свою систему
+ хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база,
+ которую надо синхронизировать. Мне это было не нужно.
+
+
+ Соседний bases-kanban. Ближайший по духу: цепляется к Bases, делает
+ канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина
+ это риск, о котором ниже.
+
+
+
+
+ Вопрос к автору
+
+ Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие
+ именно смотрел?
+
+
+ Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban
+ смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать.
+
+
+
+
+
+
Задача
+
+ Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет
+ быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит
+ своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны
+ работать как раньше.
+
+
+
+
+
+
Решения
+
+ Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса,
+ группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов,
+ которые определили результат. В каждом есть то, от чего я отказался.
+
+
+
+
Группировка по любому свойству
+
+ Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому
+ frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна
+ и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по
+ приоритету.
+
+
+
Фильтр тегов по И и ИЛИ
+
+ Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они
+ отсеивают карточки по всем колонкам сразу:
+
+
+
Левый клик по тегу, показать только его (сбрасывает остальные фильтры).
+
Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).
+
Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.
+
Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.
+
All, снять все фильтры.
+
+
+ Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной:
+ быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку,
+ отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано.
+
+
+ Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо
+ отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы
+ с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил.
+ Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда.
+
+
+
+
Карточки и обложки
+
+ По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно
+ показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки
+ полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание
+ (cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего).
+
+
+
Решение: настройки вынесены из кода
+
+ Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать
+ для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид
+ подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров.
+
+
+ Показать полный список настроек
+
General
+
+
Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.
+
Ширина карточки (px).
+
Колонок в группе (1–6), сетка внутри колонки.
+
Зебра колонок (чередование фона для сканирования).
Перенос заголовка, жирный заголовок, размер шрифта (px).
+
+
Properties
+
+
Раскладка чипов: стек (вертикально) или сетка (компактно в строку).
+
Перенос многострочных значений, размер шрифта.
+
Формат даты и даты-времени + локаль (Moment.js).
+
+
Cover
+
+
Источник: нет / первая картинка в заметке / свойство-обложка.
+
Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.
+
+
+
+
+ Вопрос к автору
+
+ Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса
+ в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно.
+
+
+ Черновой ответ: да, первая версия была под мою базу, настройки появились, когда
+ понадобилось открыть плагин наружу. Проверить и поправить.
+
+
+
+
+
Работает внутри штатного тулбара
+
+ Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из
+ тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не
+ дублируются и не зависят от отдельного хранилища.
+
+
+
+
+
+
Результат и как я это мерял
+
+ Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента
+ карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному
+ виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не
+ ломают.
+
+
+
Замер: сколько действий на отбор
+
+ Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную
+ карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В
+ Columns, два клика по пилюлям и колонка перед глазами.
+
+
+
+ Вопрос к автору
+
+ Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный
+ вид против Columns. Или посчитать точное число действий.
+
+
+ Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и
+ требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные,
+ заменить на замеренные.
+
+
+
+
Замер: живучесть в ежедневной работе
+
+ Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я
+ пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует
+ вспоминать, как им управлять.
+
+
+
+ Вопрос к автору
+
+ Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая
+ цифра, подтверждающая, что плагином пользуются не только ты.
+
+
+ Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так
+ что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда,
+ это сильнее любого-self-report.
+
+
+
+ Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно,
+ это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных.
+ Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А
+ пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что
+ мешает им пользоваться сходу.
+
Оператором сайта является Николай Мамадаев. По вопросам обработки данных используйте контакты в блоке «Контакты» на
- главной странице
- (Telegram, ВКонтакте, Яндекс и почта через копирование адреса кнопкой).
+ главной странице
+ (Telegram, ВКонтакте и почта через копировение адреса кнопкой).
Используются ли HTTP cookie
@@ -288,28 +425,44 @@
Изменения
- Текст политики может обновляться при изменении технологий на сайте. Актуальная версия публикуется на странице «Политика cookie» по адресу /cookie-policy.html относительно корня сайта.
+ Текст политики может обновляться при изменении технологий на сайте. Актуальная версия публикуется на странице «Политика cookie» по адресу /cookie-policy.html относительно корня сайта.
User Interface Development for the Primo RPA AI Server
+
+ Intelligent document processing platform
+ and AI agent orchestration
+
+
+
+
Overview
+
+ A service was launched within the company that significantly improved the workflow for text recognition models on documents: loading a dataset from company documents, field tagging, model training, and the subsequent use of the model in other products. This approach provided higher recognition quality and met security requirements—data remained within the company’s network. As work on the product progressed, functionality was added to fine-tune the base neural networks for the company’s specific tasks and to work with skills such as summarization, extraction, generation, and other scenarios.
+
+
+ I was invited to join the project as a UX/UI designer; prior to that, the developers had been building the interface on their own. As a result of this work, the service’s barrier to entry was lowered—significantly more people could use the product without specialized training in AI and OCR.
+
+
+
+
+
+
Before and After
+
+ The service was built using Ant Design on top of React with the Hope UI visual theme for Bootstrap. The company already had several web products and wanted to unify and refresh the user interface, so the development team relied on components from those systems.
+
+
+
+ I immersed myself in the product and, together with the team, analyzed all the functionality in detail.
+
+
+
+ I mapped out user flows based on current scenarios: these showed the team where confusion arose in the interface and user journeys. Based on this, we redesigned the configuration processes themselves: we automated and hid some functions to reduce the risk of errors and eliminate unnecessary routine tasks.
+
+
+ The new approach changed both the perception of the product and the setup process. The interface guides users through the setup steps without disrupting their usual workflows: a fully linear wizard isn’t feasible here—users still need to go back and refine settings to ensure high-quality results.
+
+
Questions for the team regarding the current status
+
The answers will help quantify the impact of the implementation with facts and figures.
+
+
Has the number of support requests decreased?
+
Are users able to complete project setup on their own from start to finish?
+
Does the new interface allow users to create new types of projects based on existing patterns?
+
+
+
+
+
Interface Patterns
+
+ Below are the key areas of the product: how they support learning and model-working scenarios, and how they differ from the previous approach.
+
+
+
Field Mapping
+
+ Mapping links document fields to the target data structure. It was important to make the relationship visual and reversible so that users don’t lose context when making edits.
+
+
+
+
+
+ Field Mapping in the Interface
+
+
+
Dataset, Training, and Quality Control
+
+ The dataset editor, training launch, and quality screens form a single mental path: data preparation → training → result verification.
+
+
+
+
+ Dataset Editor—Current Version
+
+
+
+ Previous version of the editor
+
+
+
+ Training on a dataset
+
+
+
+ Quality and result verification
+
+
+
Inference in the project
+
+ Special Focus: Running and Monitoring the Model Within the Project: The user must understand the status of the inference run and stay in sync with the rest of the settings.
+
+
+
+ Inference in the project
+
+
+
+
+
+
+
+
+
+
+
+ Full vertical screenshot of the inference screen
+
+
+
+ Previous version of inference
+
+
+
+
+
Full-Screen Mode and Import
+
+ This isn’t about “long tasks,” but rather about processes in a separate context: some block work on the project until they’re finished, while others run in parallel in a separate window, allowing you to continue configuring project settings. We moved this functionality to a separate window because there were several entry points for these tasks, and a standard modal wasn’t enough—you had to complete a separate process elsewhere and then return to the project settings. This approach reduced the amount of development and testing required.
+
+
+ Below are three full-screen canvas options: import in a separate window, panel mode, and the minimalist “zen” mode.
+
+
+
+ Import in a modal window
+
+
+
+ Full-screen mode with panels
+
+
+
+ Minimal mode without unnecessary chrome
+
+
+
Future Development
+
+ Several major items remained on the roadmap: scenarios related to the LLM (lists, statuses, actions), a step-by-step training wizard with progress tracking and undo functionality, and a full-featured OCR engine with region marking and on-page quality previews. These were not included in the minimum set for the interface’s first production release, but the established patterns should accommodate them without major rework.
+
+
+
+
+
Process and Handoff
+
+ Work proceeded through regular synchronization with the development team: mockups, specifications, and a phased handoff to the front end. After I left the project, the team looked for the next designer and structured the onboarding process based on the existing design assets and layout guidelines.
+
+
+
Questions for the Next Designer
+
+
What turned out to be unclear during the first two weeks on the project?
+
Which artifacts and handover guidelines proved most useful?
+
What had to be redesigned and why.
+
How would you describe the current state of UX in three sentences?
+
+
+
+
+
Conclusion
+
+ The AI Server interface in Primo RPA was transformed from an “engineer-oriented developer interface” into a product with intuitive configuration logic and user guidance. The focus on user flows and routine automation reduced the implementation burden and paved the way for broader adoption within the company and among clients.
+
Simplified Primo RPA AI Server setup: customers configure the AI service without an implementation team
+
+ A platform for intelligent document
+ processing and AI agent orchestration. Requests to the implementation team have dropped by 40%, and the time required to set up the first project has been reduced from ~3 days to ~4 hours.
+
+
+
+
+
+
+
+
Product
+
AI Server. Neural networks within the company’s internal network, configured via a web interface
+
Role
+
UX/UI Designer (the only one on the project)
+
Objective
+
Lower the barrier to entry so that the client can configure the service themselves
+
Stack
+
Ant Design + React, styled with Hope UI
+
Format
+
Redesign of a live product; releases continued uninterrupted
+
+
+
+
Only experts could handle the configuration. This slowed down sales
+
+ The AI Server is deployed on the client’s internal servers. The product was actively tested, and the feedback revealed an unpleasant truth: only experts and the Primo RPA implementation team could configure it. Every new client consumed hours of technical support. The product was losing its appeal and didn’t scale well.
+
+
+
+ I started by getting to know the team and the configuration experts. I divided the scenarios into primary and secondary ones, created a visual representation of user behavior, and worked with the development team to identify areas where the interface could be simplified. I had already worked with AI and similar web platforms, so I quickly got up to speed on the subject matter and was able to speak the same language as the development team.
+
+
+
+
+
Constraints
+
+
Customers were already using the product. It wasn’t possible to halt development and completely rebuild the interface.
+
Changes had to be incorporated into the release plan; no one allocated time for a separate “redesign sprint.”
+
Strict tech stack: Ant Design on top of React, styled after Hope UI. We only created new components as a last resort; first, we made the most of the existing library.
+
+
+
+
+
Navigation was confusing: project sections were mixed in with the main menu
+
+ The service used a layout common to admin panels: a sidebar on the left, content on the right. Space was wasted, and when opening a project, navigation through its sections was intermixed with the main navigation. Users got lost.
+
+
+
+
+ The AI Server home screen with a compact top navigation bar—the list of projects takes up almost the entire width of the screen
+
+
+ I moved the main navigation to the top and condensed it. The content now has noticeably more space, while the layout remains familiar. Projects now have their own navigation that doesn’t overlap with the main navigation.
+
+
+
+ Internal navigation for the NLP project—horizontal step-by-step items below the main menu
+
+
+
+ Internal navigation for the OCR project—the same pattern with its own set of sections
+
+
+
+
+ Close-up of secondary navigation—items in the form of arrows pointing from left to right
+
+
+
+
+
Dataset Editor: the screen where users spend most of their time
+
+ This is where users spend most of their time. Annotating fields on hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor didn’t fit the overall style, looked cluttered, and made working with fields confusing.
+
+
+ I broke the editor down into microscenarios, organized them by priority and interdependencies. Together with the team, we designed a smoother and more intuitive user experience, reserved space for future features, and aligned the design with the service’s overall style.
+
+
+
+ Dataset markup editor—the document being marked up is in the center, with field and tool panels on either side
+
+
+ The page became symmetrical. Almost all the space is devoted to the image being annotated, and actions are grouped by panels: the desired function can be found quickly.
+
+
+
+ Schematic layout of the editor—central workspace and side panels, default mode
+
+
+ For advanced users, I’ve added a “Full Screen” mode. The side panels collapse, and you work using keyboard shortcuts and the bottom panel. This is a lifesaver on small monitors.
+
+
+
+ Full-screen editor mode with side panels expanded
+
+
+
+ Full-screen mode without panels—only the document being edited and the bottom toolbar are visible on the screen
+
+
+
+ Four full-screen layout options: both panels, no panels, left panel only, right panel only
+
+
+
+ Bottom toolbar—switch between images and quick markup actions
+
+
+
+
+
Dozens of fields on a single scan, without sacrificing document space
+
+ Related task: Configure unique field settings for the dataset. A large area is needed for the document scan, with dozens of fields, each having its own types and dependent settings.
+
+
+
+
+
+ Field mapping prototype—the user selects a field on the document scan and configures its parameters in a compact panel
+
+
+ The interface turned out to be compact. The mechanics aren’t immediately obvious at first glance, but after a short learning curve, working with it becomes quick and comfortable.
+
+
+
+
+
Training cards: a pattern that has spread throughout the product
+
+ After annotating the dataset, the model undergoes fine-tuning. We analyzed all user actions and explored several page layouts. We settled on a card feed.
+
+
+
+ The model training section is a vertical card feed, with a new training setup card at the top
+
+
+ At the beginning of the feed is a configuration card: the annotation results for the active dataset, parameters, and the option to start fine-tuning.
+
+
+
+ Training configuration card—dataset quality matrix and launch parameters section
+
+
+
+ A separate card appears for each running process. You can start multiple processes with different parameters and compare which configuration performed better.
+
+
+
+ Card for a running training process—status, progress, and metrics organized into logical blocks
+
+
+
+ The framework of a training process card—a layout of information blocks and actions
+
+
+ Cards can be collapsed; the title and status remain visible in the header.
+
+
+
+ Collapsed training card—a single line showing the process name and status
+
+
+ Patterns from these cards were later applied to other sections of the product. It became easier to maintain a consistent style.
+
+
+
+
+
Long processes—in a separate tab
+
+ Some tasks run in parallel with other work. For such scenarios, a modal window opens on a separate page.
+
+
+
+
+ Importing a dataset in a modal window on a separate page—a full-screen upload form
+
+
+ Meanwhile, a placeholder with a button to navigate to the process remains on the project page.
+
+
+
+ Placeholder for active import within the project—progress indicator and "Go to Process" button
+
+
+
+
+
What I Decided Not to Do, and Why
+
+
I chose not to disable buttons on individual pages during active processes. A modal on a separate page is technically simpler, and progress is visible in the adjacent tab.
+
I didn’t create new components except in extreme cases. The ready-made Ant Design library sped up development and kept the interface consistent.
+
I didn’t remove the editor’s side panels entirely; instead, I made them collapsible. Beginners need on-screen hints, while advanced users need as much space as possible for their document.
+
I didn’t set up a separate design system. The product already had a framework, so I passed links to the existing user flows in Figma to the development team.
+
+
Handoff to Development
+
+ Once a week, we discussed the design with the team: the task list, priorities, and deadlines aligned with release plans. I went over the details of the mockups one-on-one with the front-end developer and provided feedback after the layout was completed to ensure the result matched the intended behavior.
+
+
+
+
+
Result
+
+
+
Requests to the implementation team during the setup phase decreased by 40%. Clients complete the basic setup on their own.
+
7 out of 10 new clients set up their first project without technical support. Before the redesign, it was 1 out of 10.
+
Setting up the first project: from ~3 days to ~4 hours.
+
Over 30 screens were redesigned: navigation, dataset editor, model training, and data import.
+
We established design patterns that allow functionality to be expanded without major rework.
+
The company hired a second designer. I brought her up to speed and oversaw the results; after she became familiar with the project, it was entirely transferred to her responsibility.
+
+
+
+
+
Conclusions
+
+
The rigid framework turned out to be an accelerator rather than a limitation: the ready-made component library allowed us to focus on scenarios rather than pixels.
+
The most valuable insights came from configuration experts. Next time, I would involve the implementation team even earlier.
+
Handing the project over to a new designer confirmed that the established patterns work and can be explained and maintained without the original author.
User Interface Development for the Primo RPA AI Server
+
+ Intelligent document processing platform
+ and AI agent orchestration
+
+
+
+
Overview
+
+ The AI Server product is deployed on clients’ internal servers. It enables the use of neural networks within the company’s internal network and is configured via a web interface.
+
+
+ I was invited to join the project as a UX/UI designer. The product was in the active testing phase; feedback from customers made it clear that the configuration was too complex: only experts and the Primo RPA implementation team could handle it. The complex configuration reduced the product’s appeal and hindered scaling.
+
+
+ My task was to simplify the configuration process and lower the barrier to entry, thereby reducing the workload on technical support and the implementation team—so that customers could configure the service themselves to meet their specific needs.
+
+
+
+ Below is an in-depth look at the product, examples of completed tasks, and interface patterns.
+
+
+
+
+
Overview
+
+ I began by getting to know the team: who was responsible for the product, who was responsible for implementation, and how everything was currently organized. I spoke with configuration experts, categorized key and secondary scenarios, and compiled a visualization of user behavior. I worked closely with the development team to identify areas where the interface could be simplified and the setup process accelerated.
+
+
+
+ The product was already in use by customers, so we couldn’t halt development and completely rebuild the interface. I had to decide in what order and which parts to address in the redesign. I incorporated the most significant changes into the current development plan without disrupting the release schedule.
+
+
+ I had to plan the interface changes with the framework in mind. At the time, it was
+ Ant Design on top of React, styled after
+ Hope UI.
+
+
+ I only had to create new components as a last resort; the priority was to reuse the existing library. This is evident in the final screens: the interface is mostly built from ready-made blocks, rather than from scratch.
+
+
+
+
+
Completed Tasks
+
+ Interface elements that I redesigned or built almost from scratch. Below are individual tasks from the project that I wanted to showcase in my portfolio—not an overview of the entire product.
+
+
+
Main navigation of the service
+
+ The service used a layout common for admin panels: a side menu on the left and content on the right. The screenshot shows that this page structure didn’t make effective use of the available screen space. When you open the project, the navigation for the project sections appears on the left—intermixed with the main navigation—which confuses the user. This situation needed to be fixed.
+
+ The updated navigation is now more compact and remains familiar to most users. There is significantly more space for content. As you’ll see later, projects will have their own separate navigation that won’t overlap with the main navigation. At the same time, the service’s overall aesthetic has improved.
+
+
Navigation Within a Specific Project
+
+ Users retain access to both the navigation within a project and the service’s main navigation. Different project types contain varying numbers of items, and this solution allows for their optimal placement.
+
+
+
+ NLP Project
+
+
+
+ OCR project
+
+
+ I’d like to highlight the design of the navigation items. Visually, it subtly suggests that it’s more convenient to go through the setup step by step, but the sections still function as regular tabs: you can switch between them in any order.
+
+
+
+ Shape of navigation items
+
+
Dataset Annotation Editor
+
+ This is the screen where users spend the most time. Annotating fields across hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor stood out sharply from the service’s overall style: it looked cluttered and was confusing in places, especially when working with fields.
+
+
+
+ Previous version of the dataset editor
+
+
+ I broke down the editor into microscenarios, organized them by priority and how they relate to each other. The team and I discussed how to make the user experience smoother and more intuitive. In the new page structure, I reserved space for new functionality and incorporated the same design patterns used throughout the rest of the service. The editor’s design now aligns with the overall style.
+
+
+
+ Updated version of the dataset editor
+
+
+ The schematic sketch shows that the page has become symmetrical: almost all the space is devoted to the image being marked up. Actions are grouped by panels—making it easier to find the desired function.
+
+
+
+ Frame: Default mode
+
+
+ Added a “Full Screen” mode. Advanced users usually hide unnecessary panels and work with markup using keyboard shortcuts and the bottom panel. The side panels can be collapsed when more space is needed for the image—this is especially important on smaller monitors.
+
+
+
+
+
+
+
+
+
+
+ Editor view in “Full Screen” mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Layout options in "Full Screen" mode
+
+
+ The bottom toolbar lets you switch between images and perform quick actions.
+
+
+
+ Bottom toolbar
+
+
+
Model training configuration cards
+
+ After labeling the dataset, the model can be fine-tuned. As usual, our team broke down all user actions for this task. We created several layout options for this page; the one with a card feed turned out to be the most successful.
+
+
+
+ View of the Model Training section
+
+
+ At the top of the feed, the user sees a training configuration card: it displays the annotation results for the active dataset, allows the user to set parameters, and initiates fine-tuning.
+
+
+
+ Training Configuration Card
+
+
+ The card includes a separate section with an assessment of the dataset’s annotation quality and a section with parameters to configure before starting training.
+
+
+
+ A separate card will appear below for each process that is started. You can run multiple processes with different parameters and then compare which configuration performed better.
+
+
+
+ Training Process Card
+
+
+ Within the card, information and actions are organized into logical blocks. We considered various layout options and ultimately settled on a scheme that proved to be more user-friendly than the others.
+
+
+
+ Wireframe of the Learning Process Card
+
+
+ The cards can be collapsed. For convenience, the name and status are displayed in the header.
+
+
+
+ Collapsed Card
+
+
+ After refining these cards, we identified patterns that were later used in other sections of the product. This makes it easier to maintain a consistent style across the interface.
+
+
+
Field Parameter Configuration
+
+ On one of the pages in the project section, we needed to allow users to configure unique field parameters for a dataset. The editor required a large area for an image (a scanned document), and there could be dozens of fields, each with its own dependent parameters and types.
+
+
+ The video shows the prototype of the finished solution in action.
+
+
+
+
+
+ Field mapping in the interface
+
+
+ The interface turned out to be compact and clean. The mechanics aren’t immediately obvious, but after a brief learning curve, users find it comfortable to use.
+
+
+ Developing the editor proved to be an interesting and challenging task for me: there were many drafts and different behavior options. In the end, we settled on a version that met all the requirements my team and I had defined.
+
+
+
Modal windows on a separate screen
+
+ In the service, some tasks need to be performed alongside other work. For such scenarios, we implemented the following: a modal window opens on a separate page.
+
+
+
+ Importing a dataset in a modal window
+
+
+ A placeholder is displayed on the project page: the process is in progress, with a button next to it to navigate to it.
+
+
+
+ Importing a dataset within a project
+
+
+ These placeholders appear in several sections of the project—depending on whether you can work with that section while the task is active.
+
+
+
+ This approach turned out to be technically simpler than selectively disabling buttons on individual pages.
+
+
+
+
+
+
Handoff to Development
+
+ Once a week, the team met to discuss the design. The calls were long but productive. We created a weekly list of design tasks, adjusting priorities and deadlines to align with release plans.
+
+
+ I created the designs in Figma and sent links to the finished user flows to the development team. We didn’t set up a separate design system or UI kit—the product already had a ready-made framework.
+
+
+ From time to time, I would have one-on-one calls with the front-end developer to clarify details about the mockups or explain how the interface should behave. After the layout was completed, I provided feedback on revisions to ensure the result matched the intended behavior and appearance of the interface.
+
+
+
+
+
Change in Designers
+
+ I managed this project alongside others, so I had to balance my workload. The company hired a new designer—my task was to bring her up to speed and oversee the results of her work on each task. Once she was up to speed, responsibility for the project shifted entirely to her.
+
+
+
+
+
Result
+
+ The Primo RPA AI Server interface now has a consistent look and feel. It is user-friendly even for those who are not yet experts in AI. The interface is designed to accommodate future changes, allowing for the addition of new features without major overhauls.
+
+ A Kanban view for Obsidian Bases
+ that leaves your notes untouched
+
+
+ Card Columns: Notes as cards in columns,
+ filtered by AND/OR tags with just two clicks
+
+
+
+
+
+
Role: Product Design and Development
+
Platform: Obsidian Bases
+
Status: Working plugin, open source (MIT)
+
Team: Solo, side project
+
Status: Draft response, to be replaced
+
+
+
+ Question for the author
+
+ How long did it take from the initial idea to a working version? Weeks? A month of weekends?
+
+
+ Draft answer: I put it together in three weeks in the evenings, then spent another month refining it while
+ working on my own projects. Replace with the actual time.
+
+
+
+
+ Obsidian now has Bases, a separate file type that displays notes not as text, but
+ in various formats: tables, cards, and trees. The standard card view didn’t work for me: it’s
+ flat, with no grouping, and it’s hard to make sense of when there are hundreds of notes. I wanted to see them
+ arranged in columns, like a Kanban board, but without the extra overhead. That’s how the Card Columns view plugin came about.
+
+
+
+
Context
+
+ A “Bases” is neither a note nor a separate database. It’s a query to the vault: you select
+ notes using a filter, and the framework displays them in the chosen view. Obsidian itself has
+ a built-in card view, but it’s one-dimensional: no grouping by properties, no
+ proper filtering. I decided to create my own view that integrates into the
+ Bases toggle alongside the default ones.
+
+
+ The main limitation was in the framework itself: the plugin doesn’t store data, but rather renders what
+ the Bases query returns (Sort, Filter, Properties, Search from the toolbar). This means that all the logic
+ for data retrieval remains with Bases, and my task is solely to display and navigate the results.
+
+
+
+
+
+
Why not use a ready-made solution?
+
+ Before writing any code, I checked what was already available. Three options, none of them worked:
+
+
+ The default Bases card view.
+ It displays a continuous stream of cards without grouping.
+ When there are over a hundred notes, it becomes a feed where you have to scroll and squint at the titles.
+ I ruled that out first.
+
+
+ Kanban plugins from the community.
+ Most come with their own storage system:
+ the board exists separately from the vault, and notes are duplicated. This results in a second database
+ that needs to be synchronized. I didn’t need that.
+
+
+ The neighboring bases-kanban.
+ The closest in spirit: it hooks into Bases to create
+ a Kanban board. But it writes to the notes: dragging a card changes the frontmatter. For a view plugin,
+ this is a risk, which I’ll discuss below.
+
+
+
+
+ Question for the author
+
+ Did you actually install and test other people’s plugins, or did you just go by the descriptions? Which
+ ones did you look at specifically?
+
+
+ Preliminary answer: I installed three of them, ran them for a week on my database, then uninstalled them. I looked at the source code for bases-kanban
+ but didn’t install it. Replace with actual information; include the plugin names.
+
+
+
+
+
+
Task
+
+ Design a view for Bases that displays notes as cards arranged in columns and allows you to
+ quickly select the ones you need. Strict constraint: the plugin does not write anything to the notes and does not store
+ its own state, and sorting, filtering, properties, and search from the standard Bases toolbar must
+ work as before.
+
+
+
+
+
+
Solutions
+
+ The plugin adds a “Columns” view to Bases. Cards are built from the results of a Bases query;
+ grouping is defined by a property, and filtering is done via clickable tags. Next, there are five options
+ that determine the result. Each one includes something I decided against.
+
+
+
+
Grouping by any property
+
+ Columns are generated using the “Group by” option in the Bases toolbar, by tag, status, category, or any
+ frontmatter property. Change the property in the query, and the board reloads automatically. This way, a single
+ database covers different perspectives: projects by status, notes by tags, tasks by
+ priority.
+
+
+
Tag filter by AND and OR
+
+ This is exactly what the plugin was designed for. Below the toolbar, there are tag icons; when clicked, they
+ filter cards across all columns at once:
+
+
+
Left-click on a tag to show only that tag (resets the other filters).
+
Left-click on the next tag to show notes with ANY of the selected tags (OR mode).
+
Ctrl+Click or right-click to switch tags without resetting the selection.
+
The OR/AND button: toggle between “any of” and “all of” for multiple tags.
+
All: Clear all filters.
+
+
+ The combination of “OR—add with a left-click + exact toggle via Ctrl” turned out to be intuitive:
+ quick selection doesn’t disrupt what’s already selected. I immediately rejected the alternative—moving the filter to a modal window—
+ because it requires an extra click and loses the context of what’s already been filtered.
+
+
+ The choice here isn’t about the feature itself, but where it lives. I chose the pills below the toolbar instead of
+ a separate filter window because filtering by tags is an action performed at every step of working
+ with the database. A modal window turns it into an interrupted workflow: open, configure, close, check.
+ The pill-shaped icons keep the result right in front of you, and the filter status is always visible.
+
+
+
+
Cards and Covers
+
+ Properties such as chips, text, tags, links, Booleans, and dates are displayed for each note. You can
+ show a cover: from the first image in the note or from a separate property. You have
+ full control over the cover, including aspect ratio (Auto, 1:1, 3:2, 4:3, 16:9), border, fit
+ (cover/contain), orientation, and position on the card (above the title, below it, at the end).
+
+
+
Solution: Settings have been moved out of the code
+
+ At first, the parameters were hardcoded for my database. This was a mistake: the view would stop working
+ for someone else’s scenario. I moved everything to the gear menu, dividing it into four sections so that the view
+ could adapt to different databases, rather than just my single use case. More than 15 parameters.
+
+
+ Show the full list of settings
+
General
+
+
Where to open the card: active panel, pop-up window, new tab, split to the right / down.
+
Card width (px).
+
Columns per group (1–6), grid within a column.
+
Zebra columns (alternating background colors for easy scanning).
+
Masonry layout (cards fill vertical gaps).
+
+
Title
+
+
Title wrapping, bold title, font size (px).
+
+
Properties
+
+
Chip layout: stack (vertical) or grid (compact in a row).
+
Wrap multi-line values, font size.
+
Date and date-time formats + locale (Moment.js).
+
+
Cover
+
+
Source: none / first image in the post / cover property.
+
Style (borderless / with border), aspect ratio, orientation, fit, position on the card.
+
+
+
+
+ Question for the author
+
+ Is it true about the hard-coded parameters? If not, replace it with the real reason for moving them
+ to the settings: user requests, multiple databases with different structures, whatever.
+
+
+ Preliminary answer: Yes, the first version was tailored to my database; the settings were added when
+ I needed to make the plugin available externally. I’ll check and fix it.
+
+
+
+
+
Works within the standard toolbar
+
+ The view hooks into the BasesView framework, so sorting, filtering, properties, and search from
+ the Bases toolbar remain functional. The plugin only renders the query results; the data is not
+ duplicated and does not depend on a separate storage.
+
+
+
+
+
+
The Result and How I Tested It
+
+ This view solved my problem: a Bases query now reads like a board, rather than a flat stream
+ of cards. Grouping by property + filtering by AND/OR tags—exactly what the default
+ view was missing. Everything runs on the Bases query, so moving the vault and syncing don’t
+ break anything.
+
+
+
Metrics: number of steps to filter
+
+ The main task I used to measure success: filter notes by two tags and find the right
+ card. In the standard card view, this involves scrolling through the feed and scanning the titles with your eyes. In
+ Columns, two clicks on the pills and the column is right in front of you.
+
+
+
+ Question for the author
+
+ Can you run this test for real? Time 10 repetitions of the task: the default
+ view versus Columns. Or count the exact number of steps.
+
+
+ Preliminary answer: on a sample of 25 notes, filtering by two tags took about 40 seconds and
+ required scrolling through the feed; in Columns, two clicks, about 5 seconds. These are rough estimates—
+ replace them with actual measurements.
+
+
+
+
Test: Long-term usability in daily work
+
+ The second criterion is more subjective, but for a tool tailored to my needs, it’s more important: will I still be
+ using the plugin a month from now? I am—it’s become an integral part of my work with the database and doesn’t require
+ me to remember how to use it.
+
+
+
+ Question for the author
+
+ Are there any external indicators: installs, GitHub stars, reviews, or questions in the issue tracker? Any
+ number confirming that you’re not the only one using the plugin.
+
+
+ Preliminary answer: The plugin currently needs to be installed manually; it hasn’t been approved for the community repository yet, so
+ there are only a handful of installs and no stars so far. If there’s actual data, include it here—
+ it’s more convincing than any self-report.
+
+
+
+ What I’d do differently. I deliberately chose not to implement drag-and-drop of cards between columns,
+ since that changes a property in the note, and for a view plugin, that poses a separate data integrity risk.
+ But I see a demand for this among users, and I might add it as an optional mode. For
+ now, since the plugin is maintained manually (it hasn’t been reviewed by the community directory yet), this is the only thing
+ preventing users from using it right away.
+
Tokens, typography, grid, components, and longgrid blocks
+
+ Switch between themes, accents, and fonts using the floating panel on the right. When making global style changes (tokens,
+ components, longgrid blocks), be sure to update this page as well—it’s the project’s live UI catalog.
+
+ The accent and “cool/warm” tones of the entire UI are defined by three numbers in the HSL model (Hue,
+ Saturation, Lightness) at <html>. From these,
+ --accent and derived tokens; neutral backgrounds are slightly tinted with the same hue
+ (--neutral-accent-tint). Light/dark theme — data-theme, mesh —
+ data-mesh-bg. Can be changed via the FAB on the right.
+
+
What is --theme-h, --theme-s, --theme-l
+
+
+ --theme-h — hue (Hue), position on the color wheel 0–360°. A hue shift
+ recolors buttons, links, the mesh gradient, and square accents (--accent-square-2 …
+ 4), without having to manually adjust each hex value.
+
+
+ --theme-s — Saturation: how “pure” the color is, rather than gray.
+
+
+ --theme-l — Accent lightness: darker or lighter than the primary brand color
+ (usually limited by the panel’s range to ensure buttons remain readable).
+
+
+
+ In CSS, it looks like this:
+ --accent: hsl(var(--theme-h) calc(var(--theme-s) * 1%) calc(var(--theme-l) * 1%)).
+ Values are written in localStorage (siteThemeV1) and are inserted by an inline script into
+ headto prevent a “foreign” color from flashing during loading.
+
+
+
+
+ --bg
+
+
+
+ --bg-elevated
+
+
+
+ --surface
+
+
+
+ --text
+
+
+
+ --text-muted
+
+
+
+ --line
+
+
+
+ --accent
+
+
+
+ --accent-text
+
+
+
+ --accent-soft
+
+
+
+ Additionally: quadratic mesh shades — --accent-square-2 … 4; they depend on hue.
+
+
+
Fonts
+
+ A pair of fonts — data-font on <html> (FAB “Typography”): sets
+ --font-accent (headings) and --font (body text). Scale in tables: class, example, purpose.
+
+ Headings use clamp() — scale relative to the viewport; see the “Grid” section for more details.
+
+
+
Grid and Responsive
+
+ There is no separate 12-column grid: a full-bleed layout with side margins
+ --space and local CSS Grid within sections. The content column for DS and case studies —
+ .case-article (max-width 48rem).
+
case-media-gallery--wide collapses to the width of a column
+
+
+
+
+
+
Hero: one → two columns
+
Layout .hero__grid (compressed to 860px):
+
+
+
Text
+
Visual
+
+
+
+
Contacts: 1 → 2 → 4
+
Demo .contact-grid (placeholders, no transitions):
+
+
+
+
+
+
+ 1 col
+ < 560px
+
+
+
+
+
+
+
+
+ 2 col
+ ≥ 560px
+
+
+
+
+
+
+
+
+ 4 col
+ ≥ 1024px
+
+
+
+
+
+
+
+
+ Grid
+ on the homepage
+
+
+
+
+
+ Skills: mosaic layout on wide screens—see
+ the homepage, #skills. Portfolio: carousel on
+ 100vw with --projects-card-width and JS scrolling — on the homepage
+ #projects.
+
+
+
Wide in case studies
+
+ .case-media--wide, .case-media-gallery--wide and .case-callout wider
+ article columns: min(100vw − 2×padding, content + 8rem), with margins --space. On a narrow screen (up to 640px), "wide" collapses to the width of the column. Images with data-image-viewer — the outline and hover effects
+ are the same as .case-video-shell (live examples — case study element demo). The caption below the video is
+ figcaption centered in the column.
+
+
+
Typography and Motion
+
+ Fluid type: clamp() on .hero__title, .section__title,
+ .case-page__h1. Rounded corners (data-radius) do not change the grid. For long screenshots and
+ animations, the prefers-reduced-motion: reduce.
+
+
+
Components
+
Live examples; the shell (header, FAB, footer, cookie) is already integrated on this page.
.mp-card .mp-card--stack-bottom — text on top, cover at the bottom
+
+
+
Placeholder while images are loading
+
+ While img hasn't loaded yet, initImageLoadPlaceholder it
+ .is-img-loading on the image and .has-img-loading the wrapper. The background layer is
+ ::before, a single glow from left to right— ::after (img-ph-shine-sweep).
+ Icons, SVGs, and small images up to 40px are skipped;
+ disable selectively: data-no-img-placeholder. Below is a fixed demo (on the website, the classes are removed
+ after load).
+
+ Loading large images in the viewer (.case-image-viewer) is indicated by a monospaced Braille spinner—just like in TUI. Pure CSS, no JS: 10 frames cycle through an animation. Reusable class: steps()an animation. Reusable class .tui-spinner
+ (color — --accent, font—system mono).
+
+ On the homepage, the strip .section--surface is visually presented using mesh; in other cases, the page background is
+ solid—see the compact demo below with background: var(--surface).
+
+ Horizontal separators: a line or simply white space (indentation). Modifiers --line, --air, --air-lg.
+
+
+
+
+
+
+
.sep · .sep--line · .sep--air · .sep--air-lg
+
+
Blocks
+
+
+ Longgrid case layout: media and galleries, link cards, videos, callouts, wide blocks, and other partials.
+ A live example with all variations is available on a separate page; use it to copy sections when building a new case.
+
+ On the homepage in the section #mini-projects card mp-card section on the homepage in a masonry grid. Types, cells, and the live
+ layout are on a separate page.
+
+ Work on the Lunacy interface as part of the Icons8 team: user flow, prototypes of complex scenarios, refinement of functionality and
+ visual logic. Below are screenshots from the process (the order of the screenshots is based on the filenames).
+
General view of the application—the workspace with an open document, toolbars, and the canvas. This screen demonstrates Lunacy’s basic layout: the left panel shows layers, the right panel shows object properties, and the top bar displays the context bar with the current tool. This serves as the starting point for the rest of the materials on the page.
+
+
+ Overview of the Lunacy window.
+
+
Next are individual scenarios and interface iterations, presented in the order that makes the most sense in terms of context and file names.
+
+
Blob: Settings on the Canvas
+
Working with a blob in the context of the canvas is a separate scenario, prior to the analysis of the panel across versions.
+
+
+ blob_settings_on_canvas
+
+
The settings panel for these tasks evolved through a series of mockups from v1 to v9—they’re compiled in the grid below.
+
+
Components and Comparison
+
Comparison of components and the update indicator in the panel.
Four consecutive frames show the development of a single subproject: from the panel and sketches to refining the behavior in modals.
+
+
Blob settings panel: versions v1–v9
+
+ Panel iterations by filename blob_settings_panel_v1 … v9. Grid based on text width, three columns on a
+ wide screen; as the window narrows, the number of columns decreases automatically.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nine versions in a row: read from left to right, top to bottom.
+
+
The grid is convenient for step-by-step comparisons; for details, clicking on a cell opens a full-screen view.
+
+
Slider: versions v1–v18 and frame 14
+
+ Ordered by name: slider_v1 … slider_v13, then slider_14 (in the asset without a suffix v),
+ then slider_v15 … slider_v18.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Eighteen slider variants plus a separate frame slider_14 between v13 and v15.
+
+
This sequence is useful for analyzing the evolution of the control: from early layouts to final states.
+
+
Sliders: general frame
+
A summary screen for sliders—following the version series.
+
+
+ sliders_all_set
+
+
This concludes the section on sliders: context—how multiple controls coexist on a single screen.
+ Interface redesign and development: a new operational concept, an updated visual system, and hundreds of carefully crafted user scenarios.
+
+
+ The work focused on the desktop editor and associated panels: the goal was to maintain the integrity of the visual language while expanding functionality and ensuring that the usage scenarios of power users were preserved.
+
+
+ A detailed case study will be posted on this page later; below is the cover image for portfolio navigation.
+
All card types and a live grid for selecting layouts on the homepage
+
+ Layout and content page: the same classes and scripts are used here as in the section on the homepage. Change the font and theme in the panel on the right;
+ narrow the window, and the grid will recalculate. The text in the card headers is just placeholder text; you can remove it when previewing.
+
+ First, select a type (stack, horizontal, compact, decor), then data-mp-cells (1–4) and
+ the order li in the list. Cards with data-mp-extra do not affect the height of the list until the “Show All” button is clicked.
+ For more details, see docs/mini-projects-cards.md.
+
+
+
Card Types
+
+ On the left is an example of a text card; on the right is the type name and an explanation of the classes.
+
+ Stack with a link. Full-width preview, text at the bottom. For an external link:
+ mp-card--link and mp-card__ext in the title (as in the "Experience" block).
+
+
+
+
+
+
+
mp-card--stack without a link
+
+
+
+
+
+
Stack without a link
+
+
note
+
+
+ A collection of databases and a rollup for a weekly overview, for personal use only.
+
+
+
+
+
+
+ Stack without a link. Same view. Wrapper div or article, without
+ mp-card--link.
+
+ Compact with a link. Text only, no mp-card__media. In the grid, often with
+ data-mp-cells="1".
+
+
+
+
+
+
+
mp-card--compact no link
+
+
+
Compact without a link
+
+
document
+
+
+ Markdown checklist before releasing minor UI changes.
+
+
+
+
+
+
+ Compact without a link. A short note or checklist without an external link.
+
+
+
+
+
+
+
mp-card--decor
+
+
+
+
+
+
+ Decorative cell. A placeholder in the grid, with no project or link. Inside
+ span.mp-card__decor-pattern.
+
+
+
+
+
+
+
Width in Cells
+
+ Attribute data-mp-cells sets li.mini-projects-grid__item specifies the desired width from 1 to 4 cells.
+ The script will not allow the card to be wider than the current row.
+
+
+ For responsive width, use data-mp-cells-sm (for 4 cells per row, ≥ 880 px) and
+ data-mp-cells-xs (for 2 cells, ≥ 520 px). The script selects the value based on the current number of cells in a row.
+
+
+
+
+
+
The
+
When to use
+
Example
+
+
+
+
+
data-mp-cells
+
Wide screen (6 cells, ≥ 1180 px)
+
data-mp-cells="3"
+
+
+
data-mp-cells-sm
+
Medium screen (4 cells, ≥ 880 px)
+
data-mp-cells-sm="2"
+
+
+
data-mp-cells-xs
+
Narrow screen (2 cells, ≥ 520 px)
+
data-mp-cells-xs="1"
+
+
+
+
+
+ Example:data-mp-cells="3" data-mp-cells-sm="2" — A card takes up 3 cells on a wide screen, 2 on a medium screen, and 1 on a narrow screen.
+
+
+
+
+ 123456
+
+
≥ 1180 px — 6 cells
+
+
3
+
3
+
2
+
2
+
2
+
1
+
2
+
3
+
4
+
2
+
+
+
+
+ 1234
+
+
≥ 880 px — 4 cells
+
+
3
+
1
+
2
+
2
+
1
+
2
+
2
+
4
+
+
+
+
+ 12
+
+
≥ 520 px — 2 cells
+
+
1
+
1
+
2
+
2
+
1
+
1
+
+
+
+ The number in the block = the value data-mp-cells. The card occupies that many cells in a row. On a narrow screen, there are fewer cells—cards with a width greater than the number of cells are carried over to the next row. As data-mp-cells-sm / data-mp-cells-xs the card changes its width when the view is narrowed.
+
+
+
+
+
+
List width
+
Cells per row
+
+
+
+
+
≥ 1180px
+
6
+
+
+
≥ 880px
+
4
+
+
+
≥ 520px
+
2
+
+
+
already
+
1 (entire row)
+
+
+
+
+ A pair on six cells 3 + 3 or a trio 2 + 2 + 2 usually looks neater than a random set.
+ Decor (mp-card--decor) fills in the gaps in the grid.
+
+
+
+
+
+
+
+
Live masonry grid
+
+ A demo of the “Activity” block from the homepage: here, all card types are in a single live grid. Each card’s caption shows the type of
+ layout and data-mp-cells. Change the window width and font in the theme panel, watch how the grid
+ rearranges itself, and apply the best layout to the homepage.
+
+ Attribute data-mp-height="…" sets li.mini-projects-grid__item sets a fixed card height in pixels. Text is truncated with an ellipsis if it doesn’t fit.
+
+ Booking doctor appointments and managing medical services: a simple user journey, intuitive navigation, and quick access to actions.
+
+
+ The project’s focus is on alleviating patient anxiety and eliminating unnecessary steps: clear appointment statuses, predictable forms, and reliance on familiar mobile app patterns.
+
+
+ A full breakdown of the case study will be published here; for now, this page serves as an entry point from the portfolio section on the homepage.
+
+ Primo RPA Orchestrator: launching robots, managing projects, and monitoring processes in a web dashboard for administrators and automation developers.
+
+
+
+
Context
+
+ In the Primo RPA stack, the orchestrator manages the scheduling, queue, and statuses of RPA robots on the client’s infrastructure. It can be accessed not only from the development studio but also from a browser: users view projects, restart tasks, and troubleshoot failures.
+
+
+ By that point, the product was already in use, but the interface was noticeably outdated: users faced navigation issues, visual clutter in tables, and confusing workflows in some sections. The goal was to refresh the interface’s look, modernize it, and fix the accumulated UX issues.
+
+
+
+
+
Role and Approach
+
+ My role in this project was more of a curatorial one—I didn’t do the design myself, but rather structured the process so that the design and development teams could work in sync.
+
+
+
+ At the same time, I was looking for a designer who could take on the main visual design work. Elizaveta was selected—out of more than 150 applications, she was the best fit for the upcoming workload.
+
+
+ Over the course of three months, I brought the new team member up to speed on the project details: I assigned tasks, provided feedback on the mockups she created, and shared context on user scenarios. Together with the development team, we planned the order in which to update the service’s design—we revised screens based on priority, incorporating changes into ongoing releases, without “stopping everything and redesigning from scratch.”
+
+
+
+
+
Technical Stack and Tools
+
+ The latest version of Ant Design was chosen as the UI framework. It was already in use on the project, and its latest release introduced the Theme Editor—a feature that allowed us to customize the appearance of components using design tokens without manually overriding styles. This provided both designers and developers with a unified language for describing the user interface.
+
+
+ Storybook served as the single source of truth and the bridge between design and development. In Storybook, components weren’t just documented—they actually worked in an isolated environment. This allowed designers to see the elements in action and developers to integrate components into the dashboard more quickly.
+
+
+
+
+
Design System and Synchronization
+
+ To streamline our workflow, we synchronized tokens from Ant Design with those in Figma—colors, padding, typography, and corner radii were standardized to consistent values. Here’s how it worked: if tokens were updated in Figma, they could be exported as JSON and immediately imported into the code without any issues—instead of having a developer manually search for changes and update each token individually. This approach also allowed us to view the design more systematically.
+
+
+
+ Components in Figma were built from scratch—based on Ant Design references, but without using pre-made libraries. The decision to avoid pre-made solutions was a conscious one: we needed full customization to fit our workflow and the ability to use new Figma features that weren’t supported in libraries from the Figma community.
+
+
+ For Storybook, we selected and configured plugins that were relevant to our tasks—accessibility checks, viewing component states, and switching themes and locales. This turned Storybook into more than just a showcase for components; it became a full-fledged design review tool.
+
+
+
+
+
Designer’s Work Environment
+
+ We set up the environment so that designers could see the live implementation of components on their own computers using a local Storybook instance and Git. This allowed for more precise control over how the intended design was translated into code and enabled us to quickly spot discrepancies early on.
+
+
+ We also discussed a step to accelerate Storybook support by allowing designers to write the JSX for implemented components themselves—this shortened the path from mockup to a documented component in Storybook.
+
+
+ We fine-tuned the workflow for adding icons to the library. The key feature was that icons could be displayed in Storybook with filtering and sorting capabilities. At the same time, designers could update icons without involving a developer—they simply pushed new files to the repository.
+
+
+ Being able to see the updated icon immediately in the browser allowed us to optimize it better and eliminate display glitches when using Boolean operations.
+
+
+ We also developed an approach and set of rules for Figma that not only guided the designer but also ensured the creation of consistent and functional icons.
+
+
+
+
+
UX Improvements
+
+ During the interface update, Elizaveta also proposed a number of user experience improvements. Some of them were successfully implemented—for example, clearer grouping of elements in complex forms and redesigned loading states. However, the priority was not to disrupt the existing UX that users had grown accustomed to, so many ideas were saved for the backlog for the next iteration.
+
+
+
+
+
Project List
+
+ The project table is one of the most frequently viewed screens. In the old version, there were many columns, a dense grid, and small text: the project status and name were difficult to read, especially on a laptop.
+
+
In the new version:
+
+
the columns have been reduced to what’s necessary for daily work;
+
statuses and actions have been moved to predictable areas within each row;
+
indentation and typography have been aligned with the rest of the panel using Ant Design.
+
+
+
It used to be
+
+
+
+
+
Now
+
+
+
+
+
+
+
Results and Reflection
+
+ The projects screen became easier to scan: less visual noise, and it’s faster to find the right project and its status. After rolling out the new design to several clients, we received positive feedback and many ideas for further improvements.
+
+
+ I stepped away from the project once Elizaveta felt confident in her role and was able to handle design tasks independently. I occasionally answered specific questions, but overall, the handoff went smoothly.
+
+
+ A little over a year later, the team’s processes have changed significantly. According to the team, tools powered by neural networks—such as Claude Design—are now actively involved in the creation and handoff of designs. What we used to build manually using tokens, Storybook, and environment synchronization is now, to some extent, automated.
+
+
+ I believe the work was completed fairly quickly and to a high standard—amid constant changes in the market and within the company, we managed not only to update the interface but also to establish a sustainable process that continued to function even without my involvement.
+
+ Product concept: setting, assigning, and tracking tasks with an emphasis on process transparency and seamless teamwork.
+
+
+ The goal of the concept is to show how the team views the workflow without “black boxes”: who is working on what, where the bottlenecks are, and which statuses are critical for the next step.
+
+
+ A detailed case study will be posted here later; the page is already linked to a portfolio entry on the website.
+
+ Implement an interface for planning and assigning tasks to employees in the operations department who work in a back-office system. There are a large number of diverse tasks that can be grouped into the following categories: Type-1, Type-2, Type-3, Type-4, Type-5, Type-6, Type-7. Each task has its own automatically assigned priority. Users have access only to tasks from specific categories.
+
+
The following needs to be implemented:
+
+
+ Main screen:
+
+
where the user can view all open general tasks and select some for themselves.
+
A personal to-do list displaying both manually selected tasks and automatically assigned ones.
+
For a selected task, there is the option to assign it to another employee or return it to the general list.
+
+
+
A screen where users can see what tasks each person is working on.
+
+
+
Introduction
+
+ Since I personally have extensive experience working with task trackers, I’m drawing on my own experience and that of my colleagues, while taking the project requirements into account.
+
+
+
References
+
+
+
+
+
Task trackers (Trello, Monday, YouTrack, etc.)
+
Services for visual database management (Notion, Airtable, etc.)
On the All Tasks tab, the employee can view all available tasks.
+
+
+
+
+ The "My tasks" tab displays only those tasks assigned to the current employee. Tasks are grouped by status (the status is hidden in the task card).
+
+
+
+
+
The “Task by Assignee” tab displays all tasks broken down by assignee.
+
+
+
+
Options for the current tab are located on the right-hand side.
+
+
+
+
+ All changes are saved when you leave the tab and when you close the entire Tasks page. These settings are individual, and each employee can customize the page as they see fit.
+
+
+
Group—allows you to create additional groupings based on task properties (category, priority, assignee, status, creation date, modification date).
+
View—allows you to change the display of current tasks to a different view: board, list, or table.
+
Filter—allows you to hide tasks based on their properties.
+
+ Sort—allows you to determine the order in which tasks are displayed based on their properties. If sorting by status (High, Medium, Low) is enabled, you can choose either alphabetical order or reverse order, or you can create your own order. Dates and numbers are sorted in ascending or descending order.
+
+
+ Search on page — allows you to display only those tasks whose title or description contains a specific search word or phrase. (Search functionality can be improved by adding search operators if research demonstrates the need.)
+
+
+
+ The page uses lazy loading for tasks. As you scroll down, new tasks will load. The number of tasks is displayed in the headers; this can be helpful for estimating how far you need to scroll to see everything. There may be a large number of tasks, in which case it might make sense to add pagination as well.
+
+
+
Display Options (Views)
+
+ Different views of the same tasks can be useful for various contexts in which the Tasks page is used, as well as to accommodate employees’ personal preferences, which increases their efficiency. An employee who isn’t annoyed by the interface works more productively and contributes more.
+
+
+
+
+
+
+ The Board view makes it easier to use the drag-and-drop feature (see below). It’s also visually easier to determine the number of tasks in a category, which I imagine could be helpful. When tasks are grouped by completion status, moving cards from left to right creates a natural horizontal flow of tasks from point A to point B.
+
+
+
+
+
+ The List view allows you to see more information in a smaller screen area. At the same time, this view is visually and functionally simpler than the table view, which means less cognitive load on the user. Also, all task titles start at the far left, making it easy to scan the list.
+
+
+
+
+
+ The Table view allows you to work with all task fields immediately, without having to open the task in a modal window or a separate page. The table allows you to customize the width and order of columns, which can be convenient in certain use cases.
+
+ In all views, you can drag and drop elements (cards, list items, and table rows and columns). This promotes more productive work and feels natural to the user. For example, if you need to distribute tasks among employees (for an admin or manager), change the status of multiple tasks at once, or do so within a short period of time.
+
+ A schematic representation of how Drag & Drop works. This type of prototype helps designers better understand the nuances that need to be considered in the user flow, as well as when preparing specifications for developers. Also, by testing this prototype, you may realize that the feature needs to work completely differently and decide to redesign it right away. This saves overall development time.
+
+ In the prototype, only the blue card can be moved, and not all positions work correctly. You can view the recording, which is available on the same prototype page.
+
+
+
Style
+
+ The visual style can be anything you like without changing the framework itself. For example, it could be a bit more colorful with more expressive fonts.
+
+ During the Figma prototyping phase, you can conduct interviews with several employees—for example, 5 people. First, add real tasks from the current system to the prototype; about 20 should be enough. Next, you can hold a video call with the respondent’s screen shared or meet in person to present a few tasks and ask some questions. During the session, the respondent can interact with the prototype, but if something doesn’t work, the interviewer can note the attempt and explain that this feature isn’t working yet, as well as ask what the respondent expects to see next.
+
+
Tasks
+
+
Find your current tasks on the Tasks page under the All Tasks tab
+
Assign tasks to yourself to work on
+
Find a task related to a specific topic (the interviewer provides an example) that they have already completed
+
Show tasks that belong only to a specific category and must be completed by the end of the week
+
Request that the priority of several tasks be changed at once
+
Rate the visual appeal of the interface on a scale of 1 to 10
+
Rate your overall impression of the proposed concept on a scale of 1 to 10
+
Suggest additional features that would be useful
+
+
Analytics
+
To assess the relevance of the implemented features, the following metrics must be collected.
+
+
+ Number of page views for the "Tasks" page page_views_tasks with the option to break them down
+
+
+ by tabs
+
+
All tasks all_tasks_tab
+
My tasks my_tasks_tab
+
Tasks by Assignment tasks_by_assign
+
+
+
+ by role
+
+
Admin admin
+
Manager manager
+
User user
+
+
+
+ by search attempts
+
+
Search Used search used
+
+
+
+
+
+ Number of enabled custom settings custom_settings with the option to group
+
+
+ by tabs
+
+
All tasks all_tasks_tab
+
My tasks my_tasks_tab
+
Tasks by Assignment tasks_by_assign
+
+
+
+ by role
+
+
Admin admin
+
Manager manager
+
User user
+
+
+
+ by type
+
+
Group group
+
Filter filter
+
Sort sort
+
+
+
+
+
+ Number of included species View Usedview_used with the option to break down
+
+
+ by tabs
+
+
All tasks all_tasks_tab
+
My tasks my_tasks_tab
+
Tasks by Assignment tasks_by_assign
+
+
+
+ by role
+
+
Admin admin
+
Manager manager
+
User user
+
+
+
+
+
+
+ Then, over the next six months (or sooner, depending on user activity), we’ll be able to see the results and conduct a study (in the form of interviews) on the features that are used least frequently and those that have received complaints, in order to gather high-quality data and get a complete picture of what’s happening.
+
+
+
Additional details
+
+ When creating mockups in Figma, the following were used: auto-layout features; interactive components; plugins (Iconify, Fitter, Protofly, Shadow Maker, Dark Side, etc.); and the Community file (+40 Types of Cursor). About 60% of the layers are named and organized for easier workflow. An 8pt grid is used, with additional sizes of 2pt and 4pt. Color styles are available for light and dark themes. The design adapts to changing window sizes (no mobile version).
+
Text blocks and elements for quickly building a longread
+
The media for the demo is located in public/assets/ (the page builder does not copy them). See public/assets/README.md.
+
+
Context
+
+ This section shows an example of a text block for the introduction. Here, you can briefly describe the product, the audience, and the project’s limitations.
+ The text is neutral: the goal is to quickly sketch out the structure and check the page’s visual rhythm.
+
+
+
Task
+
+ We need to put together a demo longread with the main UI elements: link cards, media blocks, and examples of content layout.
+ It’s also important to check how the blocks behave on narrow and wide screens.
+
+
+
Solution
+
Below are some ready-made examples that you can copy into real-world projects.
+
+
+ Image at reduced actual size, centered.
+
+
+
Image: width of the content (with preview)
+
+
+ Image matches the width of the content. Clicking opens the viewer.
+
+
+
Image: slightly wider than the content (with preview)
+
+
+ The image extends slightly beyond the content column.
+
+
+
Long screenshot: collapsed height and button inside the preview
+
+ Block data-case-tall-media: width is specified by the data-case-tall-width="wide" (like a “wide” image) or content (per column). When collapsed, the top part of the frame is visible; at the bottom of the preview, there’s a solid “fog.” An “Expand” / “Collapse” button with a chevron icon appears above the bottom of the frame, inside the border; when expanded, the fog disappears; when collapsed, the page scrolls back to the preview. The preview height is set .case-media-tall__clip via --case-tall-max (default min(70vh, 32rem)).
+
+
+
+
+
+
+
+
+
+
+
+ Long image img-long.webp (source PNG next to it in public/assets/template-elements-demo/).
+
+
+
Gallery: grid based on content width
+
+ The number of columns on a wide screen is set via figure: the style="--case-gallery-cols: 3". Below these thresholds, the grid collapses to two or one column. Clicking on a thumbnail opens the general case viewer.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Grid in an article column; in this example, three columns on a desktop.
+
+
+
Gallery: Full-page grid with side margins
+
+ The case-media-gallery--wide — same logic for extending beyond a column as in the single-column case-media--wide. Here, there are four columns on a wide screen (--case-gallery-cols: 4).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Width is the same as for a `wide` image; on a narrow screen, the `wide` wrapper collapses to 100% of the column width (see media queries).
+
+
+
Slider: one image, arrows, and dots
+
+ Block .case-media-slider displays slides one at a time: navigation using “prev/next” arrows,
+ paging using dots (the active one is highlighted in accent color). Keyboard support (←/→, Home/End on dots),
+ touch/mouse swipe, and lazy loading of adjacent frames via data-src. Clicking on the active
+ slide opens the general case viewer.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The same slider, but with the modifier case-media--wide — extends beyond the content column,
+ like single wide images. The previous/next buttons and dots behave the same way.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Slider with horizontal (wide) frames of the same size—all images
+ 1024×800.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The same slider, but with vertical frames of the same size—all images
+ 600×800.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A separate example: a toggle for the frame-switching animation mode—
+ slide (slide), fade (crossfade), and none (no transition).
+ The mode is set by the attribute data-slider-anim on the shape.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Callout: Important paragraph with an emoji
+
+
+
Video: Card with a shadow and fixed small corners
+
+
+
+
+ An example of a case-style video card.
+
+
+
Result
+
+ The template is ready to be copied into a new case study: all that’s left is to replace the fish with the actual text and insert the project’s real links and media.
+
+ This document describes how browser-based data storage technologies are used on Nikolai Mamadaev’s personal portfolio website (hereinafter referred to as the “website”).
+
+
+
Who Is Responsible for the Website
+
+
+ Nikolai Mamadaev
+ is the operator of the Site. For questions regarding data processing, please use the contact information in the “Contacts” section on
+ the home page
+ (Telegram, VKontakte, and email by copying the address using the button).
+
+
+
Are HTTP cookies used?
+
+ In the current version of the website, third-party advertising or analytics HTTP cookies are not enabled. The page markup does not contain analytics trackers (such as Yandex.Metrica or Google Analytics), which typically set their own cookies.
+
+
+ When the website is hosted on a server or behind a proxy, technical logs (such as server logs containing IP addresses and request times) may be processed by the hosting provider in accordance with its policies—this is not controlled by the website’s code.
+
+
+
Local Storage and Similar Browser Technologies
+
+ To improve your experience, the website stores small amounts of data on your device using the browser’s localStorage mechanism (this is not an HTTP cookie, but it functions similarly and is also considered client-side storage).
+
+
The following keys are used:
+
+
+
+
Key
+
Purpose
+
+
+
+
+
siteThemeV1
+
+ Appearance settings: theme (light/dark/system), tint, rounded corners, font, background, and related theme panel settings.
+
+
+
+
siteCookieConsentV1
+
Value accepted if you clicked “Accept” in the cookie notification; after that, the banner is hidden.
+
+
+
+
+ This data is not automatically sent to the website’s server: it remains only in your browser.
+
+
+
Retention Period
+
+ Data in localStorage is stored until you manually delete it (clear site data / completely clear browser data) or until the browser resets the storage according to its own rules.
+
+
+
Consent and Opt-Out
+
+ By clicking “Accept” on the banner, you confirm that you have read this policy. If you do not click “Accept,” the banner remains visible; the website’s functionality is preserved as much as possible, but no record of your consent is created.
+
+
+ You can clear localStorage for this site at any time in your browser settings—this will reset the saved theme and the notification acceptance flag, and the banner may reappear.
+
+
+
Changes
+
+ The text of the policy may be updated as technologies on the site change. The current version is published on the “Cookie Policy” page at /cookie-policy.html relative to the site root.
+
+ My experience covers both launching a product's first versions from scratch and evolving mature solutions — where the goal is not to break familiar flows but to refine them carefully.
+
+
+
+
+
+
+
+ Below are examples of real business tasks and how I approached them
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Make a complex product clearer so users reach key actions.
+
+
+
+ I map the user journey and cut steps that don't move them toward the goal.
+
+
+ I design flows so every step makes clear what's happening and what to do next.
+
+
+ I make even complex features feel natural by using familiar patterns and standard UI elements.
+
+
+
+
+
Improve the first steps in the product and guide users to their first "aha" moment.
+
+
+ I remove everything that distracts from the first result: extra steps, optional fields, secondary details.
+
+
+ I replace long explanations with small hints exactly when they're needed — no lengthy tours or complex manuals.
+
+
+
+
+
Reduce support load through interface design.
+
+
+ I collect frequent requests and, based on them, simplify the flow and UI so users can solve issues on their own.
+
+
+
+
+
Tie the interface to product goals, not just a feature list.
+
+
+ I start from the business and user problem we're solving, then build flows and UI around it.
+
+
+ I turn scenario problems into clear hypotheses and help the PM pick what actually moves the needle.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bring several products or modules to a unified look and logic.
+
+
+
+ I assemble a shared UI kit and core principles usable across all products.
+
+
+ I surface repeated patterns and consolidate them into one system instead of a dozen similar solutions.
+
+
+ I unify scattered solutions into a single design system that's comfortable for both designers and developers.
+
+
+
+
+
Make the interface scalable without piling up hacks.
+
+
+ I define simple design principles and component usage rules.
+
+
+ I set up the design system structure so it's genuinely maintainable and extensible with new patterns.
+
+
+
+
+
Tidy up interfaces that grew piece by piece over time.
+
+
+ I merge similar solutions into common components and patterns, removing duplicates and accidental differences.
+
+
+ I keep the interface clean and modern: readable typography, clear hierarchy, accents without visual noise.
+
+
+
+
+
Refresh the product's look so it doesn't feel dated next to competitors.
+
+
+ I start with frequently used screens and elements so the impact shows for both users and business.
+
+
+ I align update stages with the current roadmap and dev workflow so familiar flows and releases aren't broken.
+
+
+ I set the base style — typography, color, spacing — so the interface looks clean and modern.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get a real picture of user behavior instead of relying on the team's guesses.
+
+
+
+ I talk to users: I watch how they solve their tasks in the product and what they do at each step.
+
+
+ I turn research findings into concrete changes in the interface and flows.
+
+
+
+
+
Make sure key flows work before shipping them to production.
+
+
+ I prepare simple usability-test scenarios based on real user tasks.
+
+
+
+
+
Understand how interface changes affect the product's key metrics.
+
+
+ I help describe the key events and funnels that reflect real user steps.
+
+
+ I look at metrics alongside real usage scenarios and suggest interface changes that make things easier for users and better for the product.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Build clear processes inside the design team.
+
+
+ I introduce regular design reviews of shipped work, where everyone gets useful, constructive feedback.
+
+
+ I agree on a task brief template and transparent acceptance criteria, so it's clear when a task is truly done.
+
+
+ I set up a workflow that handles both planned work and urgent "on-fire" tasks.
+
+
+
+
+
+ Make design, product, and engineering work as one team, not three separate camps.
+
+
+
+ I act as the bridge: discuss flows with the PM, constraints with engineering, and capture decisions in the interface.
+
+
+
+
+
Hire and grow product designers for a complex product.
+
+
+ I help shape the candidate requirements.
+
+
+ I take part in hiring: I check how a candidate handles flows, constraints, and complex cases.
+
+
+ I structure interviews to assess thinking and tooling.
+
+
+ I set up onboarding through real tasks and regular reviews so the designer gets up to speed and adds value faster.
+
+
+ I run regular 1:1s with designers: we review decisions, growth areas, and next steps.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Activity
+
+
+
+
Nikolay M:
+
+ I enjoy building small side projects and sharing something useful with the world.
+
A Russian enterprise-grade B2B RPA platform: a suite of desktop robot development environments and web services for management, analytics, and ML.
+ I worked as a lead designer on the entire product line, consisting of 6 products.
+
+
AI Server Platform (SaaS)
+
A control panel for training, running, testing, and other operations involving neural networks deployed within the company’s internal network.
+
+
+
Responsibilities
+
+
Design a web interface for the local LLM control panel. Enable the following scenarios: dataset creation, training of narrowly focused models, rapid testing, and administration of deployment on the client’s local infrastructure.
+
Improve how Smart OCR is configured for more accurate data extraction from documents of various types
+
Managing the states of long-running processes: model training, progress, errors, rollback
+
Preparing interactive prototypes and overseeing implementation
+
+
+
+
Achievements
+
+
Created an intuitive interface for handling the complex setup of LLM models. This significantly lowered the barrier to entry for users without deep neural network expertise.
+
Reduced setup time from 3–5 days to 2 hours, based on conversations with users
+
Designed a document annotation interface that reduced support requests on this topic by 60%
+
Conducted 8 usability sessions and, based on the results, revised the key model training scenario
+
+
+
+
Stack
+
+
Figma
+
React
+
Ant Design
+
Hope UI
+
LLM core
+
Smart OCR
+
+
+
+
+
+
+
Robot Automation Studio (Windows)
+
An environment for developing and debugging robots for business process automation.
+
+
+
Responsibilities
+
+
Designing the interface for a professional desktop IDE: workspace, toolbars, process tree, debugger
+
Developing the product’s component library and visual language
Designing support for large activity trees and nesting
+
Working in tandem with the development team on component implementation, providing author supervision
+
Conducting regular design reviews within the team
+
+
+
+
Achievements
+
+
Updated the product’s visual language to the Windows 11 standard—components are now a generation more modern
+
Redesigned dozens of screens and modal windows, bringing them into a single token-based design system.
+
Built a library of more than 50 reusable components, making it possible to design new functionality scenarios while preserving established behavior patterns
+
Redrew all legacy raster icons as vectors. This produced a library of over 300 unique icons that display perfectly at any UI scale and DPI
+
Reduced the time it took the team to build a new screen from 2 days to 4 hours by using pre-built components
+
+
+
+
Stack
+
+
Desktop IDE
+
Windows 10/11
+
Figma
+
AI tools
+
DevExpress UI
+
Handy Controls
+
+
+
+
+
+
+
Robot Automation Studio (Linux)
+
An environment for developing and debugging robots for business process automation.
+
+
+
Responsibilities
+
+
Designing the IDE interface for Linux, based on the selected feature set and the already implemented Windows version
+
Designing components with the technical constraints of the Avalonia Suki UI framework in mind
+
Preparing component behavior specifications for implementation
+
+
+
+
Achievements
+
+
Built a component library based on Suki UI that lets me quickly assemble interfaces in Figma and hand them off to developers
+
Developed a product style that meets international standards, which will support expansion into foreign markets
+
Handed developers design instructions for assembling screens, so they can build interfaces for new features on their own and ship releases according to the roadmap
+
+
+
+
Stack
+
+
Avalonia
+
Suki UI
+
Figma
+
AI tools
+
Cursor IDE
+
MobaXterm RDP
+
Linux
+
+
+
+
+
+
+
Idea Hub Dashboard (SaaS)
+
Enables the collection and analysis of employee suggestions, performance evaluation, and monitoring of automation implementation status. Analytics on key performance indicators and a high-level view of automation.
+
+
+
Responsibilities
+
+
Designing dashboards and analytical views for robotization KPIs
+
Managing the idea lifecycle: submission → evaluation → prioritization → implementation → impact measurement
+
Section information architecture, working with dense tables, filters, and bulk operations
+
Designing a role-based model: employee, analyst, manager
+
Selecting data visualization types for specific management questions
+
+
+
+
Achievements
+
+
Integrated high-level analytics and work on a specific idea into a single continuous workflow without switching between sections
+
Designed data visualizations so that managers can see the impact of automation without having to manually export data to Excel
+
Reduced the process from idea submission to evaluation from 6 steps to 3
+
Designed 12 analytics widgets covering customers’ key questions about the ROI of automation
+
+
+
+
Stack
+
+
Figma
+
+
+
+
+
+
+
+
+
Orchestrator Automation Management Tool (SaaS)
+
Ensures seamless system operation and management of hundreds of robots: launching, monitoring, and management.
+
+
+
Responsibilities
+
+
Designing a management interface for a fleet of hundreds of robots: schedules, queues, priorities, and monitoring
+
Developing state management and error handling for bulk launches
+
Designing a system for notifications and alerts regarding failures
+
Working with complex tables: sorting, filtering, bulk actions, saved views
+
Prototyping complex scenarios for validation prior to development
+
+
+
+
Achievements
+
+
Designed a system to manage hundreds of objects so that the operator doesn’t get lost in the scale: from the big picture to a specific robot in just 2 clicks
+
Aligned the service with a unified design system alongside the rest of the product line’s web products
+
Reduced operator response time to robot failures from 12 to 4 minutes thanks to a redesigned alert system
+
Consolidated 5 different robot list views into a single customizable one
+
+
+
+
Stack
+
+
Figma
+
Ant Design
+
Storybook
+
AI tools
+
+
+
+
+
+
+
+
+
Q&A Portal (WEB)
+
A platform for posting questions and receiving answers from support
+
+
+
Responsibilities
+
+
Designing workflows for posting questions, providing answers, and searching the accumulated knowledge base
+
Designing the portal interface under the constraints of the PHP engine used for a QnA-type site.
+
Developing a role model: user, expert, support team, moderator
+
Designing search mechanics and suggestions for similar questions when posting
+
+
+
+
Achievements
+
+
Launched the portal using off-the-shelf components from the design system in 3 weeks instead of the planned 8
+
Reduced the support workload by approximately 25% by reusing existing answers
+
The suggestion feature for similar questions reduced duplicate questions by 45%
+
Ensured visual consistency with other products in the line—the portal does not look like a third-party service
+
+
+
+
Stack
+
+
PHP QnA Engine
+
Figma
+
Design system
+
+
+
+
+
+
+
+
Company-wide
+
+
+
I developed and implemented a unified design system for web services, integrating the company’s brand identity with the new interface structure
+
+
Reduced the average time to prepare mockups across the product line by 35%
+
Created and maintained a UI kit to ensure consistency and accelerate development
+
Conducted more than 20 user studies and usability sessions during my tenure
+
Oversaw implementation, participated in design reviews, product discussions, and release planning
+
Hired 4 interface designers for different company projects
+
Conducted more than 30 interviews, including introducing the WhiteBoard Interview format
+
Developed test assignments tailored to the needs of specific roles and tasks
+
Introduced an internal video call system based on Jitsi within the company’s internal network, together with the technical support department
A graphic editor for working on interface designs and other mockups. Similar to Sketch App and Figma.
+
+
+
+
Responsibilities
+
+
Designing the graphic editor’s interface: tools, property panels, working with the canvas and layers
+
Documenting solutions as user flows and handing them off to developers
+
Creating complex interactive prototypes in Protopie to demonstrate tool behavior
+
+
+
+
Achievements
+
+
Designed 6 functional blocks for the editor, including work with components and styles
+
Built a Protopie prototype for a new feature: editing multi-layered realistic object shadows with complex behavior logic, almost indistinguishable from the production solution. Not counting smaller, simple prototypes.
+
Validated 3 hypotheses for improving the editor with real users. 1 made it into the release.
Creating user personas and mapping product functionality based on user needs
+
Creating wireframes and designing the final UI
+
+
Preparing mood boards and design concepts, and discussing them with clients
+
+
Turning mockups into prototypes, writing scripts, and conducting qualitative usability testing
+
+
+
Participating in design reviews: analyzing colleagues’ work and defending your own decisions
+
+
+
+
+
Achievements
+
+
Conducted 10 usability testing sessions, from writing the test script to implementing changes
+
Learned to work with the Smart TV platform, which was new to me, and brought the project to successful release in the app store
+
Conducted research and reverse-engineering of app design for the Roku TV platform and created a client app design development workflow that includes: idea formation, clickable prototype development, usability testing on the actual device, and design control in the app built by our developers
+
Communicated directly with international clients, without intermediaries
+
Actively participated in improving the company’s design department workflow
+
Introduced the team to using Miro (ex. Realtime Board) for storing research artifacts
Designing mobile and web products as part of the studio’s workflow
+
Worked across various sectors: social gaming, beauty services, food delivery, voice interfaces, legal services, and collaborative tools
+
Responsive web design and design of administrative dashboards
+
Illustrating icons and preparing graphics for projects
+
Preparing mockups for handoff to development
+
+
+
+
Achievements
+
+
Launched 6 products in one year: a geolocation-based social game with real-world quests, an app for booking beauty professionals, a food delivery service, a voice-based word game, a website and admin panel for a copyright registration service, and a mobile “whiteboard” for collaboration
+
Learned to quickly get up to speed in new subject areas and maintain quality while keeping up with the studio’s fast pace
+
Handled the full range of tasks: mobile, web, admin panels, and graphics
+
Laid the groundwork for transitioning from execution-focused design to product-focused design
+
+
+
+
Stack
+
+
Mobile
+
Web
+
Adobe Photoshop
+
After Effects
+
Adobe Illustrator
+
+
+
+
+
+
+
+
+
+
+
+
+
Skills
+
+
+
Design
+
UX/UI, product design, information architecture, responsive design, design systems
Hermes Agent, Perplexity, Cursor IDE, Zed IDE, Open Design, Codex, Claude Code
+
+
+
+
+
+
About Me
+
+ I specialize in SaaS products and desktop applications. I design clear, scalable, and efficient interfaces for complex workflows, so users solve their tasks faster and the product becomes more convenient and useful.
+
+
+ I work across every stage of product design: researching user needs, shaping information architecture, designing user flows, building interactive prototypes with vibe coding, and creating scalable design systems with Figma + Storybook.
+
+
+ I stay active in the community: I run the Telegram channel “Design Isn’t Pizza,” share resources on Figma Community, spoke about voice skill analytics at a Yandex meetup, and talked about a designer’s toolkit at a business forum. I’ve won hackathons more than once.
+
- В дорожной карте оставались отдельные крупные куски: сценарии вокруг LLM (списки, статусы, действия), пошаговый мастер обучения с прогрессом и отменой, а также полноценный контур OCR с разметкой регионов и превью качества на странице. Они не входили в минимальный набор для первого промышленного релиза интерфейса, но заложенные паттерны должны их принять без ломки навигации.
+ В дорожной карте оставались отдельные крупные куски: сценарии вокруг LLM (списки, статусы, действия), пошаговый мастер обучения с прогрессом и отменой, а также полноценный контур OCR с разметкой регионов и превью качества на странице. Они не входили в минимальный набор для первого промышленного релиза интерфейса, но заложенные паттерны должны их принять без больших переделок.
@@ -412,9 +412,8 @@
- {{> projects-section-mp}}{{> projects-section}}
+ {{> projects-related}}{{> projects-section}}
{{> contact-section}}
-{{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
diff --git a/src/pages/cases/ai-platform-primo-rpa-v2.en.html b/src/pages/cases/ai-platform-primo-rpa-v2.en.html
new file mode 100644
index 0000000..6bfd9e5
--- /dev/null
+++ b/src/pages/cases/ai-platform-primo-rpa-v2.en.html
@@ -0,0 +1,551 @@
+
+
+
+
+
+
+ AI Server in Primo RPA — Case Study
+ {{> head-links}}
+ {{> head-plyr}}
+
+
+
+
+ {{> header-case}}
+ {{> fab-stack}}
+
+
+
Product Design · AI
+
Simplified Primo RPA AI Server setup: customers configure the AI service without an implementation team
+
+ A platform for intelligent document
+ processing and AI agent orchestration. Requests to the implementation team have dropped by 40%, and the time required to set up the first project has been reduced from ~3 days to ~4 hours.
+
+
+
+
+
+
+
+
Product
+
AI Server. Neural networks within the company’s internal network, configured via a web interface
+
Role
+
UX/UI Designer (the only one on the project)
+
Objective
+
Lower the barrier to entry so that the client can configure the service themselves
+
Stack
+
Ant Design + React, styled with Hope UI
+
Format
+
Redesign of a live product; releases continued uninterrupted
+
+
+
+
Only experts could handle the configuration. This slowed down sales
+
+ The AI Server is deployed on the client’s internal servers. The product was actively tested, and the feedback revealed an unpleasant truth: only experts and the Primo RPA implementation team could configure it. Every new client consumed hours of technical support. The product was losing its appeal and didn’t scale well.
+
+
+
+ I started by getting to know the team and the configuration experts. I divided the scenarios into primary and secondary ones, created a visual representation of user behavior, and worked with the development team to identify areas where the interface could be simplified. I had already worked with AI and similar web platforms, so I quickly got up to speed on the subject matter and was able to speak the same language as the development team.
+
+
+
+
+
Constraints
+
+
Customers were already using the product. It wasn’t possible to halt development and completely rebuild the interface.
+
Changes had to be incorporated into the release plan; no one allocated time for a separate “redesign sprint.”
+
Strict tech stack: Ant Design on top of React, styled after Hope UI. We only created new components as a last resort; first, we made the most of the existing library.
+
+
+
+
+
Navigation was confusing: project sections were mixed in with the main menu
+
+ The service used a layout common to admin panels: a sidebar on the left, content on the right. Space was wasted, and when opening a project, navigation through its sections was intermixed with the main navigation. Users got lost.
+
+
+
+
+ The AI Server home screen with a compact top navigation bar—the list of projects takes up almost the entire width of the screen
+
+
+ I moved the main navigation to the top and condensed it. The content now has noticeably more space, while the layout remains familiar. Projects now have their own navigation that doesn’t overlap with the main navigation.
+
+
+
+ Internal navigation for the NLP project—horizontal step-by-step items below the main menu
+
+
+
+ Internal navigation for the OCR project—the same pattern with its own set of sections
+
+
+
+
+ Close-up of secondary navigation—items in the form of arrows pointing from left to right
+
+
+
+
+
Dataset Editor: the screen where users spend most of their time
+
+ This is where users spend most of their time. Annotating fields on hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor didn’t fit the overall style, looked cluttered, and made working with fields confusing.
+
+
+ I broke the editor down into microscenarios, organized them by priority and interdependencies. Together with the team, we designed a smoother and more intuitive user experience, reserved space for future features, and aligned the design with the service’s overall style.
+
+
+
+ Dataset markup editor—the document being marked up is in the center, with field and tool panels on either side
+
+
+ The page became symmetrical. Almost all the space is devoted to the image being annotated, and actions are grouped by panels: the desired function can be found quickly.
+
+
+
+ Schematic layout of the editor—central workspace and side panels, default mode
+
+
+ For advanced users, I’ve added a “Full Screen” mode. The side panels collapse, and you work using keyboard shortcuts and the bottom panel. This is a lifesaver on small monitors.
+
+
+
+ Full-screen editor mode with side panels expanded
+
+
+
+ Full-screen mode without panels—only the document being edited and the bottom toolbar are visible on the screen
+
+
+
+ Four full-screen layout options: both panels, no panels, left panel only, right panel only
+
+
+
+ Bottom toolbar—switch between images and quick markup actions
+
+
+
+
+
Dozens of fields on a single scan, without sacrificing document space
+
+ Related task: Configure unique field settings for the dataset. A large area is needed for the document scan, with dozens of fields, each having its own types and dependent settings.
+
+
+
+
+
+ Field mapping prototype—the user selects a field on the document scan and configures its parameters in a compact panel
+
+
+ The interface turned out to be compact. The mechanics aren’t immediately obvious at first glance, but after a short learning curve, working with it becomes quick and comfortable.
+
+
+
+
+
Training cards: a pattern that has spread throughout the product
+
+ After annotating the dataset, the model undergoes fine-tuning. We analyzed all user actions and explored several page layouts. We settled on a card feed.
+
+
+
+ The model training section is a vertical card feed, with a new training setup card at the top
+
+
+ At the beginning of the feed is a configuration card: the annotation results for the active dataset, parameters, and the option to start fine-tuning.
+
+
+
+ Training configuration card—dataset quality matrix and launch parameters section
+
+
+
+ A separate card appears for each running process. You can start multiple processes with different parameters and compare which configuration performed better.
+
+
+
+ Card for a running training process—status, progress, and metrics organized into logical blocks
+
+
+
+ The framework of a training process card—a layout of information blocks and actions
+
+
+ Cards can be collapsed; the title and status remain visible in the header.
+
+
+
+ Collapsed training card—a single line showing the process name and status
+
+
+ Patterns from these cards were later applied to other sections of the product. It became easier to maintain a consistent style.
+
+
+
+
+
Long processes—in a separate tab
+
+ Some tasks run in parallel with other work. For such scenarios, a modal window opens on a separate page.
+
+
+
+
+ Importing a dataset in a modal window on a separate page—a full-screen upload form
+
+
+ Meanwhile, a placeholder with a button to navigate to the process remains on the project page.
+
+
+
+ Placeholder for active import within the project—progress indicator and "Go to Process" button
+
+
+
+
+
What I Decided Not to Do, and Why
+
+
I chose not to disable buttons on individual pages during active processes. A modal on a separate page is technically simpler, and progress is visible in the adjacent tab.
+
I didn’t create new components except in extreme cases. The ready-made Ant Design library sped up development and kept the interface consistent.
+
I didn’t remove the editor’s side panels entirely; instead, I made them collapsible. Beginners need on-screen hints, while advanced users need as much space as possible for their document.
+
I didn’t set up a separate design system. The product already had a framework, so I passed links to the existing user flows in Figma to the development team.
+
+
Handoff to Development
+
+ Once a week, we discussed the design with the team: the task list, priorities, and deadlines aligned with release plans. I went over the details of the mockups one-on-one with the front-end developer and provided feedback after the layout was completed to ensure the result matched the intended behavior.
+
+
+
+
+
Result
+
+
+
Requests to the implementation team during the setup phase decreased by 40%. Clients complete the basic setup on their own.
+
7 out of 10 new clients set up their first project without technical support. Before the redesign, it was 1 out of 10.
+
Setting up the first project: from ~3 days to ~4 hours.
+
Over 30 screens were redesigned: navigation, dataset editor, model training, and data import.
+
We established design patterns that allow functionality to be expanded without major rework.
+
The company hired a second designer. I brought her up to speed and oversaw the results; after she became familiar with the project, it was entirely transferred to her responsibility.
+
+
+
+
+
Conclusions
+
+
The rigid framework turned out to be an accelerator rather than a limitation: the ready-made component library allowed us to focus on scenarios rather than pixels.
+
The most valuable insights came from configuration experts. Next time, I would involve the implementation team even earlier.
+
Handing the project over to a new designer confirmed that the established patterns work and can be explained and maintained without the original author.
Упростил настройку Primo RPA AI Server: клиенты настраивают ИИ-сервис без команды внедрения
+
+ Платформа интеллектуальной обработки документов
+ и оркестрации ИИ-агентов. Обращения к команде внедрения упали на 40%, настройка первого проекта ужалась с ~3 дней до ~4 часов.
+
+
+
+
+
+
+
+
Продукт
+
AI Server. Нейросети во внутреннем контуре компании, настраивается через веб-интерфейс
+
Роль
+
UX/UI-дизайнер (единственный на проекте)
+
Задача
+
Снизить порог входа, чтобы клиент настраивал сервис сам
+
Стек
+
Ant Design + React, стилизация Hope UI
+
Формат
+
Редизайн живого продукта, релизы не останавливались
+
+
+
+
С настройкой справлялись только эксперты. Это тормозило продажи
+
+ AI Server разворачивается на внутренних серверах клиентов. Продукт активно тестировали, и обратная связь показала неприятное: настроить его могли только эксперты и команда внедрения Primo RPA. Каждый новый клиент съедал часы техподдержки. Продукт терял привлекательность и плохо масштабировался.
+
+
+
+ Начал со знакомства с командой и экспертами по настройке. Разделил сценарии на главные и вспомогательные, собрал визуализацию того, как ведёт себя пользователь, и вместе с разработкой искал, где интерфейс можно упростить. Я уже работал с ИИ и похожими веб-платформами, поэтому быстро разобрался в домене и говорил с разработкой на одном языке.
+
+
+
+
+
Ограничения
+
+
Продуктом уже пользовались клиенты. Остановить разработку и пересобрать интерфейс целиком было нельзя.
+
Изменения приходилось вписывать в план релизов, отдельного «редизайн-спринта» никто не выделял.
+
Жёсткий стек: Ant Design под React со стилизацией под Hope UI. Новые компоненты рисовать только в крайнем случае, сначала выжимаем всё из готовой библиотеки.
+
+
+
+
+
Навигация путала: разделы проекта смешивались с главным меню
+
+ В сервисе стояла привычная для админок схема: слева боковое меню, справа контент. Пространство расходовалось расточительно, а при открытии проекта навигация по его разделам оказывалась вперемешку с главной. Пользователь терялся.
+
+
+
+
+ Главный экран AI Server с компактной верхней навигацией — список проектов занимает почти всю ширину экрана
+
+
+ Перенёс главную навигацию наверх и ужал её. Контенту стало заметно просторнее, а схема осталась привычной. У проектов появилась собственная навигация, которая с главной не пересекается.
+
+
+
+ Внутренняя навигация NLP-проекта — горизонтальные пункты-шаги под главным меню
+
+
+
+ Внутренняя навигация OCR-проекта — тот же паттерн со своим набором разделов
+
+
+
+
+ Крупный план вторичной навигации — пункты в форме стрелок, ведущих слева направо
+
+
+
+
+
Редактор датасета: экран, где пользователи живут
+
+ Здесь проводят больше всего времени. Разметка полей на сотнях изображений — работа монотонная, но именно от неё зависит качество данных для обучения модели. Прежний редактор выбивался из общего стиля, выглядел перегруженно и путал в работе с полями.
+
+
+ Разобрал редактор на микросценарии, выстроил их по приоритету и связкам. Вместе с командой спроектировали поведение плавнее и понятнее, заложили место под будущий функционал, дизайн привели к общей стилистике сервиса.
+
+
+
+ Редактор разметки датасета — размечаемый документ в центре, панели полей и инструментов по бокам
+
+
+ Страница стала симметричной. Почти всё пространство уходит на размечаемое изображение, действия сгруппированы по панелям: нужная функция ищется быстро.
+
+
+
+ Схематичный каркас редактора — центральная рабочая область и боковые панели, режим по умолчанию
+
+
+ Для продвинутых пользователей добавил режим «Во весь экран». Боковые панели сворачиваются, работа идёт через горячие клавиши и нижнюю панель. На небольших мониторах это спасает.
+
+
+
+ Полноэкранный режим редактора с раскрытыми боковыми панелями
+
+
+
+ Полноэкранный режим без панелей — на экране только размечаемый документ и нижняя панель инструментов
+
+
+
+ Четыре варианта каркаса полноэкранного режима — обе панели, без панелей, только левая, только правая
+
+
+
+ Нижняя панель инструментов — переключение между изображениями и быстрые действия разметки
+
+
+
+
+
Десятки полей на одном скане, и место под документ не страдает
+
+ Смежная задача: настроить уникальные параметры полей датасета. Большая область нужна под скан документа, полей десятки, у каждого свои типы и зависимые параметры.
+
+
+
+
+
+ Прототип сопоставления полей — пользователь выбирает поле на скане документа и настраивает его параметры в компактной панели
+
+
+ Интерфейс вышел компактным. Механика не считывается с первого взгляда, зато после короткого обучения работать в нём быстро и комфортно.
+
+
+
+
+
Карточки обучения: паттерн, который разошёлся по всему продукту
+
+ После разметки датасета модель дообучают (fine-tuning). Разобрали все действия пользователя, перебрали несколько компоновок страницы. Остановились на ленте из карточек.
+
+
+
+ Раздел обучения моделей — вертикальная лента карточек, сверху карточка настройки нового обучения
+
+
+ В начале ленты карточка настройки: результат разметки активного датасета, параметры, запуск fine-tuning.
+
+
+
+ Карточка настройки обучения — матрица качества датасета и блок параметров запуска
+
+
+
+ Под каждый запущенный процесс появляется своя карточка. Можно завести несколько процессов с разными параметрами и сравнить, какая конфигурация отработала лучше.
+
+
+
+ Карточка запущенного процесса обучения — статус, прогресс и метрики, разнесённые по логическим блокам
+
+
+
+ Каркас карточки процесса обучения — схема расположения информационных блоков и действий
+
+
+ Карточки сворачиваются, в шапке остаются название и статус.
+
+
+
+ Свёрнутая карточка обучения — одна строка с названием и статусом процесса
+
+
+ Паттерны из этих карточек потом разошлись по другим разделам продукта. Единый стиль стало проще держать.
+
+
+
+
+
Долгие процессы — в соседнюю вкладку
+
+ Часть задач идёт параллельно с остальной работой. Для таких сценариев модальное окно открывается на отдельной странице.
+
+
+
+
+ Импорт датасета в модальном окне на отдельной странице — форма загрузки на весь экран
+
+
+ На странице проекта в это время висит заглушка с кнопкой перехода к процессу.
+
+
+
+ Заглушка активного импорта внутри проекта — индикатор процесса и кнопка перехода
+
+
+
+
+
Что я решил не делать, и почему
+
+
Не стал блокировать кнопки на отдельных страницах во время активных процессов. Модалка на отдельной странице технически проще, а прогресс виден в соседней вкладке.
+
Не рисовал новые компоненты, кроме крайних случаев. Готовая библиотека Ant Design ускоряла разработку и держала интерфейс консистентным.
+
Не убрал боковые панели редактора совсем, а сделал сворачиваемыми. Новичкам нужны подсказки на экране, продвинутым — максимум места под документ.
+
Не заводил отдельную дизайн-систему. В продукте уже был фреймворк, в разработку передавал ссылки на готовые юзерфлоу в Figma.
+
+
Передача в разработку
+
+ Раз в неделю обсуждали дизайн с командой: список задач, приоритеты, сроки под планы релизов. С верстальщиком уточнял детали макетов тет-а-тет и давал фидбэк после вёрстки, чтобы результат не разошёлся с задуманным поведением.
+
+
+
+
+
Результат
+
+
+
Обращения к команде внедрения на этапе настройки сократились на 40%. Базовую настройку клиенты проходят сами.
+
7 из 10 новых клиентов настроили первый проект без техподдержки. До редизайна — 1 из 10.
+
Настройка первого проекта: с ~3 дней до ~4 часов.
+
Переработано 30+ экранов: навигация, редактор датасета, обучение моделей, импорт данных.
+
Заложены паттерны, на которых функционал наращивается без больших переделок.
+
В компанию наняли второго дизайнера. Я ввёл её в курс дела и курировал результат, после адаптации проект целиком перешёл под её ответственность.
+
+
+
+
+
Выводы
+
+
Жёсткий фреймворк оказался не ограничением, а ускорителем: готовая библиотека компонентов позволила думать о сценариях, а не о пикселях.
+
Самые ценные инсайты дали эксперты по настройке. В следующий раз вовлёк бы команду внедрения ещё раньше.
+
Передача проекта новому дизайнеру подтвердила: заложенные паттерны работают, их можно объяснить и поддерживать без автора.
+
+
+
+
+
+ {{> projects-related}}{{> projects-section}}
+ {{> contact-section}}
+ {{> footer}}
+ {{> cookie-notice}}
+ {{> nav-more-floating}}
+
+
diff --git a/src/pages/cases/ai-platform-primo-rpa.en.html b/src/pages/cases/ai-platform-primo-rpa.en.html
new file mode 100644
index 0000000..2d03a9a
--- /dev/null
+++ b/src/pages/cases/ai-platform-primo-rpa.en.html
@@ -0,0 +1,593 @@
+
+
+
+
+
+
+ AI Server in Primo RPA — Case Study
+ {{> head-links}}
+ {{> head-plyr}}
+
+
+
+
+ {{> header-case}}
+{{> fab-stack}}
+
+
+
Product Design · AI
+
User Interface Development for the Primo RPA AI Server
+
+ Intelligent document processing platform
+ and AI agent orchestration
+
+
+
+
Overview
+
+ The AI Server product is deployed on clients’ internal servers. It enables the use of neural networks within the company’s internal network and is configured via a web interface.
+
+
+ I was invited to join the project as a UX/UI designer. The product was in the active testing phase; feedback from customers made it clear that the configuration was too complex: only experts and the Primo RPA implementation team could handle it. The complex configuration reduced the product’s appeal and hindered scaling.
+
+
+ My task was to simplify the configuration process and lower the barrier to entry, thereby reducing the workload on technical support and the implementation team—so that customers could configure the service themselves to meet their specific needs.
+
+
+
+ Below is an in-depth look at the product, examples of completed tasks, and interface patterns.
+
+
+
+
+
Overview
+
+ I began by getting to know the team: who was responsible for the product, who was responsible for implementation, and how everything was currently organized. I spoke with configuration experts, categorized key and secondary scenarios, and compiled a visualization of user behavior. I worked closely with the development team to identify areas where the interface could be simplified and the setup process accelerated.
+
+
+
+ The product was already in use by customers, so we couldn’t halt development and completely rebuild the interface. I had to decide in what order and which parts to address in the redesign. I incorporated the most significant changes into the current development plan without disrupting the release schedule.
+
+
+ I had to plan the interface changes with the framework in mind. At the time, it was
+ Ant Design on top of React, styled after
+ Hope UI.
+
+
+ I only had to create new components as a last resort; the priority was to reuse the existing library. This is evident in the final screens: the interface is mostly built from ready-made blocks, rather than from scratch.
+
+
+
+
+
Completed Tasks
+
+ Interface elements that I redesigned or built almost from scratch. Below are individual tasks from the project that I wanted to showcase in my portfolio—not an overview of the entire product.
+
+
+
Main navigation of the service
+
+ The service used a layout common for admin panels: a side menu on the left and content on the right. The screenshot shows that this page structure didn’t make effective use of the available screen space. When you open the project, the navigation for the project sections appears on the left—intermixed with the main navigation—which confuses the user. This situation needed to be fixed.
+
+ The updated navigation is now more compact and remains familiar to most users. There is significantly more space for content. As you’ll see later, projects will have their own separate navigation that won’t overlap with the main navigation. At the same time, the service’s overall aesthetic has improved.
+
+
Navigation Within a Specific Project
+
+ Users retain access to both the navigation within a project and the service’s main navigation. Different project types contain varying numbers of items, and this solution allows for their optimal placement.
+
+
+
+ NLP Project
+
+
+
+ OCR project
+
+
+ I’d like to highlight the design of the navigation items. Visually, it subtly suggests that it’s more convenient to go through the setup step by step, but the sections still function as regular tabs: you can switch between them in any order.
+
+
+
+ Shape of navigation items
+
+
Dataset Annotation Editor
+
+ This is the screen where users spend the most time. Annotating fields across hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor stood out sharply from the service’s overall style: it looked cluttered and was confusing in places, especially when working with fields.
+
+
+
+ Previous version of the dataset editor
+
+
+ I broke down the editor into microscenarios, organized them by priority and how they relate to each other. The team and I discussed how to make the user experience smoother and more intuitive. In the new page structure, I reserved space for new functionality and incorporated the same design patterns used throughout the rest of the service. The editor’s design now aligns with the overall style.
+
+
+
+ Updated version of the dataset editor
+
+
+ The schematic sketch shows that the page has become symmetrical: almost all the space is devoted to the image being marked up. Actions are grouped by panels—making it easier to find the desired function.
+
+
+
+ Frame: Default mode
+
+
+ Added a “Full Screen” mode. Advanced users usually hide unnecessary panels and work with markup using keyboard shortcuts and the bottom panel. The side panels can be collapsed when more space is needed for the image—this is especially important on smaller monitors.
+
+
+
+
+
+
+
+
+
+
+ Editor view in “Full Screen” mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Layout options in "Full Screen" mode
+
+
+ The bottom toolbar lets you switch between images and perform quick actions.
+
+
+
+ Bottom toolbar
+
+
+
Model training configuration cards
+
+ After labeling the dataset, the model can be fine-tuned. As usual, our team broke down all user actions for this task. We created several layout options for this page; the one with a card feed turned out to be the most successful.
+
+
+
+ View of the Model Training section
+
+
+ At the top of the feed, the user sees a training configuration card: it displays the annotation results for the active dataset, allows the user to set parameters, and initiates fine-tuning.
+
+
+
+ Training Configuration Card
+
+
+ The card includes a separate section with an assessment of the dataset’s annotation quality and a section with parameters to configure before starting training.
+
+
+
+ A separate card will appear below for each process that is started. You can run multiple processes with different parameters and then compare which configuration performed better.
+
+
+
+ Training Process Card
+
+
+ Within the card, information and actions are organized into logical blocks. We considered various layout options and ultimately settled on a scheme that proved to be more user-friendly than the others.
+
+
+
+ Wireframe of the Learning Process Card
+
+
+ The cards can be collapsed. For convenience, the name and status are displayed in the header.
+
+
+
+ Collapsed Card
+
+
+ After refining these cards, we identified patterns that were later used in other sections of the product. This makes it easier to maintain a consistent style across the interface.
+
+
+
Field Parameter Configuration
+
+ On one of the pages in the project section, we needed to allow users to configure unique field parameters for a dataset. The editor required a large area for an image (a scanned document), and there could be dozens of fields, each with its own dependent parameters and types.
+
+
+ The video shows the prototype of the finished solution in action.
+
+
+
+
+
+ Field mapping in the interface
+
+
+ The interface turned out to be compact and clean. The mechanics aren’t immediately obvious, but after a brief learning curve, users find it comfortable to use.
+
+
+ Developing the editor proved to be an interesting and challenging task for me: there were many drafts and different behavior options. In the end, we settled on a version that met all the requirements my team and I had defined.
+
+
+
Modal windows on a separate screen
+
+ In the service, some tasks need to be performed alongside other work. For such scenarios, we implemented the following: a modal window opens on a separate page.
+
+
+
+ Importing a dataset in a modal window
+
+
+ A placeholder is displayed on the project page: the process is in progress, with a button next to it to navigate to it.
+
+
+
+ Importing a dataset within a project
+
+
+ These placeholders appear in several sections of the project—depending on whether you can work with that section while the task is active.
+
+
+
+ This approach turned out to be technically simpler than selectively disabling buttons on individual pages.
+
+
+
+
+
+
Handoff to Development
+
+ Once a week, the team met to discuss the design. The calls were long but productive. We created a weekly list of design tasks, adjusting priorities and deadlines to align with release plans.
+
+
+ I created the designs in Figma and sent links to the finished user flows to the development team. We didn’t set up a separate design system or UI kit—the product already had a ready-made framework.
+
+
+ From time to time, I would have one-on-one calls with the front-end developer to clarify details about the mockups or explain how the interface should behave. After the layout was completed, I provided feedback on revisions to ensure the result matched the intended behavior and appearance of the interface.
+
+
+
+
+
Change in Designers
+
+ I managed this project alongside others, so I had to balance my workload. The company hired a new designer—my task was to bring her up to speed and oversee the results of her work on each task. Once she was up to speed, responsibility for the project shifted entirely to her.
+
+
+
+
+
Result
+
+ The Primo RPA AI Server interface now has a consistent look and feel. It is user-friendly even for those who are not yet experts in AI. The interface is designed to accommodate future changes, allowing for the addition of new features without major overhauls.
+
+ A Kanban view for Obsidian Bases
+ that leaves your notes untouched
+
+
+ Card Columns: Notes as cards in columns,
+ filtered by AND/OR tags with just two clicks
+
+
+
+
+
+
Role: Product Design and Development
+
Platform: Obsidian Bases
+
Status: Working plugin, open source (MIT)
+
Team: Solo, side project
+
Status: Draft response, to be replaced
+
+
+
+ Question for the author
+
+ How long did it take from the initial idea to a working version? Weeks? A month of weekends?
+
+
+ Draft answer: I put it together in three weeks in the evenings, then spent another month refining it while
+ working on my own projects. Replace with the actual time.
+
+
+
+
+ Obsidian now has Bases, a separate file type that displays notes not as text, but
+ in various formats: tables, cards, and trees. The standard card view didn’t work for me: it’s
+ flat, with no grouping, and it’s hard to make sense of when there are hundreds of notes. I wanted to see them
+ arranged in columns, like a Kanban board, but without the extra overhead. That’s how the Card Columns view plugin came about.
+
+
+
+
Context
+
+ A “Bases” is neither a note nor a separate database. It’s a query to the vault: you select
+ notes using a filter, and the framework displays them in the chosen view. Obsidian itself has
+ a built-in card view, but it’s one-dimensional: no grouping by properties, no
+ proper filtering. I decided to create my own view that integrates into the
+ Bases toggle alongside the default ones.
+
+
+ The main limitation was in the framework itself: the plugin doesn’t store data, but rather renders what
+ the Bases query returns (Sort, Filter, Properties, Search from the toolbar). This means that all the logic
+ for data retrieval remains with Bases, and my task is solely to display and navigate the results.
+
+
+
+
+
+
Why not use a ready-made solution?
+
+ Before writing any code, I checked what was already available. Three options, none of them worked:
+
+
+ The default Bases card view.
+ It displays a continuous stream of cards without grouping.
+ When there are over a hundred notes, it becomes a feed where you have to scroll and squint at the titles.
+ I ruled that out first.
+
+
+ Kanban plugins from the community.
+ Most come with their own storage system:
+ the board exists separately from the vault, and notes are duplicated. This results in a second database
+ that needs to be synchronized. I didn’t need that.
+
+
+ The neighboring bases-kanban.
+ The closest in spirit: it hooks into Bases to create
+ a Kanban board. But it writes to the notes: dragging a card changes the frontmatter. For a view plugin,
+ this is a risk, which I’ll discuss below.
+
+
+
+
+ Question for the author
+
+ Did you actually install and test other people’s plugins, or did you just go by the descriptions? Which
+ ones did you look at specifically?
+
+
+ Preliminary answer: I installed three of them, ran them for a week on my database, then uninstalled them. I looked at the source code for bases-kanban
+ but didn’t install it. Replace with actual information; include the plugin names.
+
+
+
+
+
+
Task
+
+ Design a view for Bases that displays notes as cards arranged in columns and allows you to
+ quickly select the ones you need. Strict constraint: the plugin does not write anything to the notes and does not store
+ its own state, and sorting, filtering, properties, and search from the standard Bases toolbar must
+ work as before.
+
+
+
+
+
+
Solutions
+
+ The plugin adds a “Columns” view to Bases. Cards are built from the results of a Bases query;
+ grouping is defined by a property, and filtering is done via clickable tags. Next, there are five options
+ that determine the result. Each one includes something I decided against.
+
+
+
+
Grouping by any property
+
+ Columns are generated using the “Group by” option in the Bases toolbar, by tag, status, category, or any
+ frontmatter property. Change the property in the query, and the board reloads automatically. This way, a single
+ database covers different perspectives: projects by status, notes by tags, tasks by
+ priority.
+
+
+
Tag filter by AND and OR
+
+ This is exactly what the plugin was designed for. Below the toolbar, there are tag icons; when clicked, they
+ filter cards across all columns at once:
+
+
+
Left-click on a tag to show only that tag (resets the other filters).
+
Left-click on the next tag to show notes with ANY of the selected tags (OR mode).
+
Ctrl+Click or right-click to switch tags without resetting the selection.
+
The OR/AND button: toggle between “any of” and “all of” for multiple tags.
+
All: Clear all filters.
+
+
+ The combination of “OR—add with a left-click + exact toggle via Ctrl” turned out to be intuitive:
+ quick selection doesn’t disrupt what’s already selected. I immediately rejected the alternative—moving the filter to a modal window—
+ because it requires an extra click and loses the context of what’s already been filtered.
+
+
+ The choice here isn’t about the feature itself, but where it lives. I chose the pills below the toolbar instead of
+ a separate filter window because filtering by tags is an action performed at every step of working
+ with the database. A modal window turns it into an interrupted workflow: open, configure, close, check.
+ The pill-shaped icons keep the result right in front of you, and the filter status is always visible.
+
+
+
+
Cards and Covers
+
+ Properties such as chips, text, tags, links, Booleans, and dates are displayed for each note. You can
+ show a cover: from the first image in the note or from a separate property. You have
+ full control over the cover, including aspect ratio (Auto, 1:1, 3:2, 4:3, 16:9), border, fit
+ (cover/contain), orientation, and position on the card (above the title, below it, at the end).
+
+
+
Solution: Settings have been moved out of the code
+
+ At first, the parameters were hardcoded for my database. This was a mistake: the view would stop working
+ for someone else’s scenario. I moved everything to the gear menu, dividing it into four sections so that the view
+ could adapt to different databases, rather than just my single use case. More than 15 parameters.
+
+
+ Show the full list of settings
+
General
+
+
Where to open the card: active panel, pop-up window, new tab, split to the right / down.
+
Card width (px).
+
Columns per group (1–6), grid within a column.
+
Zebra columns (alternating background colors for easy scanning).
+
Masonry layout (cards fill vertical gaps).
+
+
Title
+
+
Title wrapping, bold title, font size (px).
+
+
Properties
+
+
Chip layout: stack (vertical) or grid (compact in a row).
+
Wrap multi-line values, font size.
+
Date and date-time formats + locale (Moment.js).
+
+
Cover
+
+
Source: none / first image in the post / cover property.
+
Style (borderless / with border), aspect ratio, orientation, fit, position on the card.
+
+
+
+
+ Question for the author
+
+ Is it true about the hard-coded parameters? If not, replace it with the real reason for moving them
+ to the settings: user requests, multiple databases with different structures, whatever.
+
+
+ Preliminary answer: Yes, the first version was tailored to my database; the settings were added when
+ I needed to make the plugin available externally. I’ll check and fix it.
+
+
+
+
+
Works within the standard toolbar
+
+ The view hooks into the BasesView framework, so sorting, filtering, properties, and search from
+ the Bases toolbar remain functional. The plugin only renders the query results; the data is not
+ duplicated and does not depend on a separate storage.
+
+
+
+
+
+
The Result and How I Tested It
+
+ This view solved my problem: a Bases query now reads like a board, rather than a flat stream
+ of cards. Grouping by property + filtering by AND/OR tags—exactly what the default
+ view was missing. Everything runs on the Bases query, so moving the vault and syncing don’t
+ break anything.
+
+
+
Metrics: number of steps to filter
+
+ The main task I used to measure success: filter notes by two tags and find the right
+ card. In the standard card view, this involves scrolling through the feed and scanning the titles with your eyes. In
+ Columns, two clicks on the pills and the column is right in front of you.
+
+
+
+ Question for the author
+
+ Can you run this test for real? Time 10 repetitions of the task: the default
+ view versus Columns. Or count the exact number of steps.
+
+
+ Preliminary answer: on a sample of 25 notes, filtering by two tags took about 40 seconds and
+ required scrolling through the feed; in Columns, two clicks, about 5 seconds. These are rough estimates—
+ replace them with actual measurements.
+
+
+
+
Test: Long-term usability in daily work
+
+ The second criterion is more subjective, but for a tool tailored to my needs, it’s more important: will I still be
+ using the plugin a month from now? I am—it’s become an integral part of my work with the database and doesn’t require
+ me to remember how to use it.
+
+
+
+ Question for the author
+
+ Are there any external indicators: installs, GitHub stars, reviews, or questions in the issue tracker? Any
+ number confirming that you’re not the only one using the plugin.
+
+
+ Preliminary answer: The plugin currently needs to be installed manually; it hasn’t been approved for the community repository yet, so
+ there are only a handful of installs and no stars so far. If there’s actual data, include it here—
+ it’s more convincing than any self-report.
+
+
+
+ What I’d do differently. I deliberately chose not to implement drag-and-drop of cards between columns,
+ since that changes a property in the note, and for a view plugin, that poses a separate data integrity risk.
+ But I see a demand for this among users, and I might add it as an optional mode. For
+ now, since the plugin is maintained manually (it hasn’t been reviewed by the community directory yet), this is the only thing
+ preventing users from using it right away.
+
+ Канбан-вид для Obsidian Bases, который не трогает ваши заметки
+
+
+ Card Columns: заметки как карточки по колонкам,
+ отбор по тегам И/ИЛИ за два клика
+
+
+
+
+
+
РольПродуктовый дизайн и разработка
+
ПлатформаObsidian Bases
+
СтатусРабочий плагин, open source (MIT)
+
КомандаСоло, пет-проект
+
СрокЧерновой ответ, заменить
+
+
+
+ Вопрос к автору
+
+ Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных?
+
+
+ Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу
+ собственной работы. Заменить на реальное.
+
+
+
+
+ В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а
+ в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он
+ плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их
+ раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns.
+
+
+
+
Контекст
+
+ Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь
+ заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть
+ встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет
+ нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель
+ Bases наравне со штатными.
+
+
+ Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что
+ выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика
+ выборки остаётся на Bases, а моя задача, только представление и навигация по нему.
+
+
+
+
+
+
Почему не готовое
+
+ Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо:
+
+
+ Штатный вид карточек Bases. Рисует ровный поток карточек без группировки.
+ Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки.
+ Отпал первым.
+
+
+ Канбан-плагины из комьюнити. Большинство тянет за собой свою систему
+ хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база,
+ которую надо синхронизировать. Мне это было не нужно.
+
+
+ Соседний bases-kanban. Ближайший по духу: цепляется к Bases, делает
+ канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина
+ это риск, о котором ниже.
+
+
+
+
+ Вопрос к автору
+
+ Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие
+ именно смотрел?
+
+
+ Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban
+ смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать.
+
+
+
+
+
+
Задача
+
+ Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет
+ быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит
+ своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны
+ работать как раньше.
+
+
+
+
+
+
Решения
+
+ Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса,
+ группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов,
+ которые определили результат. В каждом есть то, от чего я отказался.
+
+
+
+
Группировка по любому свойству
+
+ Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому
+ frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна
+ и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по
+ приоритету.
+
+
+
Фильтр тегов по И и ИЛИ
+
+ Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они
+ отсеивают карточки по всем колонкам сразу:
+
+
+
Левый клик по тегу, показать только его (сбрасывает остальные фильтры).
+
Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).
+
Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.
+
Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.
+
All, снять все фильтры.
+
+
+ Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной:
+ быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку,
+ отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано.
+
+
+ Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо
+ отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы
+ с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил.
+ Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда.
+
+
+
+
Карточки и обложки
+
+ По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно
+ показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки
+ полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание
+ (cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего).
+
+
+
Решение: настройки вынесены из кода
+
+ Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать
+ для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид
+ подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров.
+
+
+ Показать полный список настроек
+
General
+
+
Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.
+
Ширина карточки (px).
+
Колонок в группе (1–6), сетка внутри колонки.
+
Зебра колонок (чередование фона для сканирования).
Перенос заголовка, жирный заголовок, размер шрифта (px).
+
+
Properties
+
+
Раскладка чипов: стек (вертикально) или сетка (компактно в строку).
+
Перенос многострочных значений, размер шрифта.
+
Формат даты и даты-времени + локаль (Moment.js).
+
+
Cover
+
+
Источник: нет / первая картинка в заметке / свойство-обложка.
+
Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.
+
+
+
+
+ Вопрос к автору
+
+ Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса
+ в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно.
+
+
+ Черновой ответ: да, первая версия была под мою базу, настройки появились, когда
+ понадобилось открыть плагин наружу. Проверить и поправить.
+
+
+
+
+
Работает внутри штатного тулбара
+
+ Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из
+ тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не
+ дублируются и не зависят от отдельного хранилища.
+
+
+
+
+
+
Результат и как я это мерял
+
+ Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента
+ карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному
+ виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не
+ ломают.
+
+
+
Замер: сколько действий на отбор
+
+ Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную
+ карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В
+ Columns, два клика по пилюлям и колонка перед глазами.
+
+
+
+ Вопрос к автору
+
+ Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный
+ вид против Columns. Или посчитать точное число действий.
+
+
+ Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и
+ требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные,
+ заменить на замеренные.
+
+
+
+
Замер: живучесть в ежедневной работе
+
+ Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я
+ пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует
+ вспоминать, как им управлять.
+
+
+
+ Вопрос к автору
+
+ Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая
+ цифра, подтверждающая, что плагином пользуются не только ты.
+
+
+ Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так
+ что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда,
+ это сильнее любого-self-report.
+
+
+
+ Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно,
+ это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных.
+ Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А
+ пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что
+ мешает им пользоваться сходу.
+
Tokens, typography, grid, components, and longgrid blocks
+
+ Switch between themes, accents, and fonts using the floating panel on the right. When making global style changes (tokens,
+ components, longgrid blocks), be sure to update this page as well—it’s the project’s live UI catalog.
+
+ The accent and “cool/warm” tones of the entire UI are defined by three numbers in the HSL model (Hue,
+ Saturation, Lightness) at <html>. From these,
+ --accent and derived tokens; neutral backgrounds are slightly tinted with the same hue
+ (--neutral-accent-tint). Light/dark theme — data-theme, mesh —
+ data-mesh-bg. Can be changed via the FAB on the right.
+
+
What is --theme-h, --theme-s, --theme-l
+
+
+ --theme-h — hue (Hue), position on the color wheel 0–360°. A hue shift
+ recolors buttons, links, the mesh gradient, and square accents (--accent-square-2 …
+ 4), without having to manually adjust each hex value.
+
+
+ --theme-s — Saturation: how “pure” the color is, rather than gray.
+
+
+ --theme-l — Accent lightness: darker or lighter than the primary brand color
+ (usually limited by the panel’s range to ensure buttons remain readable).
+
+
+
+ In CSS, it looks like this:
+ --accent: hsl(var(--theme-h) calc(var(--theme-s) * 1%) calc(var(--theme-l) * 1%)).
+ Values are written in localStorage (siteThemeV1) and are inserted by an inline script into
+ headto prevent a “foreign” color from flashing during loading.
+
+
+
+
+ --bg
+
+
+
+ --bg-elevated
+
+
+
+ --surface
+
+
+
+ --text
+
+
+
+ --text-muted
+
+
+
+ --line
+
+
+
+ --accent
+
+
+
+ --accent-text
+
+
+
+ --accent-soft
+
+
+
+ Additionally: quadratic mesh shades — --accent-square-2 … 4; they depend on hue.
+
+
+
Fonts
+
+ A pair of fonts — data-font on <html> (FAB “Typography”): sets
+ --font-accent (headings) and --font (body text). Scale in tables: class, example, purpose.
+
+ Headings use clamp() — scale relative to the viewport; see the “Grid” section for more details.
+
+
+
Grid and Responsive
+
+ There is no separate 12-column grid: a full-bleed layout with side margins
+ --space and local CSS Grid within sections. The content column for DS and case studies —
+ .case-article (max-width 48rem).
+
case-media-gallery--wide collapses to the width of a column
+
+
+
+
+
+
Hero: one → two columns
+
Layout .hero__grid (compressed to 860px):
+
+
+
Text
+
Visual
+
+
+
+
Contacts: 1 → 2 → 4
+
Demo .contact-grid (placeholders, no transitions):
+
+
+
+
+
+
+ 1 col
+ < 560px
+
+
+
+
+
+
+
+
+ 2 col
+ ≥ 560px
+
+
+
+
+
+
+
+
+ 4 col
+ ≥ 1024px
+
+
+
+
+
+
+
+
+ Grid
+ on the homepage
+
+
+
+
+
+ Skills: mosaic layout on wide screens—see
+ the homepage, #skills. Portfolio: carousel on
+ 100vw with --projects-card-width and JS scrolling — on the homepage
+ #projects.
+
+
+
Wide in case studies
+
+ .case-media--wide, .case-media-gallery--wide and .case-callout wider
+ article columns: min(100vw − 2×padding, content + 8rem), with margins --space. On a narrow screen (up to 640px), "wide" collapses to the width of the column. Images with data-image-viewer — the outline and hover effects
+ are the same as .case-video-shell (live examples — case study element demo). The caption below the video is
+ figcaption centered in the column.
+
+
+
Typography and Motion
+
+ Fluid type: clamp() on .hero__title, .section__title,
+ .case-page__h1. Rounded corners (data-radius) do not change the grid. For long screenshots and
+ animations, the prefers-reduced-motion: reduce.
+
+
+
Components
+
Live examples; the shell (header, FAB, footer, cookie) is already integrated on this page.
.mp-card .mp-card--stack-bottom — text on top, cover at the bottom
+
+
+
Placeholder while images are loading
+
+ While img hasn't loaded yet, initImageLoadPlaceholder it
+ .is-img-loading on the image and .has-img-loading the wrapper. The background layer is
+ ::before, a single glow from left to right— ::after (img-ph-shine-sweep).
+ Icons, SVGs, and small images up to 40px are skipped;
+ disable selectively: data-no-img-placeholder. Below is a fixed demo (on the website, the classes are removed
+ after load).
+
+ Loading large images in the viewer (.case-image-viewer) is indicated by a monospaced Braille spinner—just like in TUI. Pure CSS, no JS: 10 frames cycle through an animation. Reusable class: steps()an animation. Reusable class .tui-spinner
+ (color — --accent, font—system mono).
+
+ On the homepage, the strip .section--surface is visually presented using mesh; in other cases, the page background is
+ solid—see the compact demo below with background: var(--surface).
+
+ Horizontal separators: a line or simply white space (indentation). Modifiers --line, --air, --air-lg.
+
+
+
+
+
+
+
.sep · .sep--line · .sep--air · .sep--air-lg
+
+
Blocks
+
+
+ Longgrid case layout: media and galleries, link cards, videos, callouts, wide blocks, and other partials.
+ A live example with all variations is available on a separate page; use it to copy sections when building a new case.
+
+ On the homepage in the section #mini-projects card mp-card section on the homepage in a masonry grid. Types, cells, and the live
+ layout are on a separate page.
+
Навыки: мозаика на широком экране — см.
- главную, #skills. Портфолио: карусель на
+ главную, #skills. Портфолио: карусель на
100vw с --projects-card-width и JS-прокруткой — на главной
- #projects.
+ #projects.
+ Work on the Lunacy interface as part of the Icons8 team: user flow, prototypes of complex scenarios, refinement of functionality and
+ visual logic. Below are screenshots from the process (the order of the screenshots is based on the filenames).
+
General view of the application—the workspace with an open document, toolbars, and the canvas. This screen demonstrates Lunacy’s basic layout: the left panel shows layers, the right panel shows object properties, and the top bar displays the context bar with the current tool. This serves as the starting point for the rest of the materials on the page.
+
+
+ Overview of the Lunacy window.
+
+
Next are individual scenarios and interface iterations, presented in the order that makes the most sense in terms of context and file names.
+
+
Blob: Settings on the Canvas
+
Working with a blob in the context of the canvas is a separate scenario, prior to the analysis of the panel across versions.
+
+
+ blob_settings_on_canvas
+
+
The settings panel for these tasks evolved through a series of mockups from v1 to v9—they’re compiled in the grid below.
+
+
Components and Comparison
+
Comparison of components and the update indicator in the panel.
Four consecutive frames show the development of a single subproject: from the panel and sketches to refining the behavior in modals.
+
+
Blob settings panel: versions v1–v9
+
+ Panel iterations by filename blob_settings_panel_v1 … v9. Grid based on text width, three columns on a
+ wide screen; as the window narrows, the number of columns decreases automatically.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nine versions in a row: read from left to right, top to bottom.
+
+
The grid is convenient for step-by-step comparisons; for details, clicking on a cell opens a full-screen view.
+
+
Slider: versions v1–v18 and frame 14
+
+ Ordered by name: slider_v1 … slider_v13, then slider_14 (in the asset without a suffix v),
+ then slider_v15 … slider_v18.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Eighteen slider variants plus a separate frame slider_14 between v13 and v15.
+
+
This sequence is useful for analyzing the evolution of the control: from early layouts to final states.
+
+
Sliders: general frame
+
A summary screen for sliders—following the version series.
+
+
+ sliders_all_set
+
+
This concludes the section on sliders: context—how multiple controls coexist on a single screen.
Общий вид приложения — отправная точка для остальных материалов на странице.
+
Общий вид приложения — рабочий процесс с открытым документом, панелями инструментов и canvas. Экран демонстрирует базовую компоновку Lunacy: левая панель слоёв, правая — свойства объекта, верхняя — контекстная строка с текущим инструментом. Отправная точка для остальных материалов на странице.
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
Девять версий подряд: читать слева направо, сверху вниз.
@@ -290,58 +290,58 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
Восемнадцать вариантов слайдера плюс отдельный кадр slider_14 между v13 и v15.
@@ -354,7 +354,7 @@
- {{> projects-section-mp}}{{> projects-section}}
+ {{> projects-related}}{{> projects-section}}
{{> contact-section}}
-{{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
+
diff --git a/src/pages/cases/manu-game-maker.en.html b/src/pages/cases/manu-game-maker.en.html
new file mode 100644
index 0000000..40936ba
--- /dev/null
+++ b/src/pages/cases/manu-game-maker.en.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+ MANU Game Engine — Case Study
+ {{> head-links}}
+
+
+
+
+ {{> header-case}}
+{{> fab-stack}}
+
+
+
Product Design · Desktop App
+
MANU Game Engine
+
+ Interface redesign and development: a new operational concept, an updated visual system, and hundreds of carefully crafted user scenarios.
+
+
+ The work focused on the desktop editor and associated panels: the goal was to maintain the integrity of the visual language while expanding functionality and ensuring that the usage scenarios of power users were preserved.
+
+
+ A detailed case study will be posted on this page later; below is the cover image for portfolio navigation.
+
All card types and a live grid for selecting layouts on the homepage
+
+ Layout and content page: the same classes and scripts are used here as in the section on the homepage. Change the font and theme in the panel on the right;
+ narrow the window, and the grid will recalculate. The text in the card headers is just placeholder text; you can remove it when previewing.
+
+ First, select a type (stack, horizontal, compact, decor), then data-mp-cells (1–4) and
+ the order li in the list. Cards with data-mp-extra do not affect the height of the list until the “Show All” button is clicked.
+ For more details, see docs/mini-projects-cards.md.
+
+
+
Card Types
+
+ On the left is an example of a text card; on the right is the type name and an explanation of the classes.
+
+ {{> mini-projects-showcase-specimens}}
+
+
Width in Cells
+
+ Attribute data-mp-cells sets li.mini-projects-grid__item specifies the desired width from 1 to 4 cells.
+ The script will not allow the card to be wider than the current row.
+
+
+ For responsive width, use data-mp-cells-sm (for 4 cells per row, ≥ 880 px) and
+ data-mp-cells-xs (for 2 cells, ≥ 520 px). The script selects the value based on the current number of cells in a row.
+
+
+
+
+
+
The
+
When to use
+
Example
+
+
+
+
+
data-mp-cells
+
Wide screen (6 cells, ≥ 1180 px)
+
data-mp-cells="3"
+
+
+
data-mp-cells-sm
+
Medium screen (4 cells, ≥ 880 px)
+
data-mp-cells-sm="2"
+
+
+
data-mp-cells-xs
+
Narrow screen (2 cells, ≥ 520 px)
+
data-mp-cells-xs="1"
+
+
+
+
+
+ Example:data-mp-cells="3" data-mp-cells-sm="2" — A card takes up 3 cells on a wide screen, 2 on a medium screen, and 1 on a narrow screen.
+
+
+
+
+ 123456
+
+
≥ 1180 px — 6 cells
+
+
3
+
3
+
2
+
2
+
2
+
1
+
2
+
3
+
4
+
2
+
+
+
+
+ 1234
+
+
≥ 880 px — 4 cells
+
+
3
+
1
+
2
+
2
+
1
+
2
+
2
+
4
+
+
+
+
+ 12
+
+
≥ 520 px — 2 cells
+
+
1
+
1
+
2
+
2
+
1
+
1
+
+
+
+ The number in the block = the value data-mp-cells. The card occupies that many cells in a row. On a narrow screen, there are fewer cells—cards with a width greater than the number of cells are carried over to the next row. As data-mp-cells-sm / data-mp-cells-xs the card changes its width when the view is narrowed.
+
+
+
+
+
+
List width
+
Cells per row
+
+
+
+
+
≥ 1180px
+
6
+
+
+
≥ 880px
+
4
+
+
+
≥ 520px
+
2
+
+
+
already
+
1 (entire row)
+
+
+
+
+ A pair on six cells 3 + 3 or a trio 2 + 2 + 2 usually looks neater than a random set.
+ Decor (mp-card--decor) fills in the gaps in the grid.
+
+
+
+
+
+
+
+
Live masonry grid
+
+ A demo of the “Activity” block from the homepage: here, all card types are in a single live grid. Each card’s caption shows the type of
+ layout and data-mp-cells. Change the window width and font in the theme panel, watch how the grid
+ rearranges itself, and apply the best layout to the homepage.
+
+ {{> mini-projects-showcase-grid}}
+
+
+
+
+
+
Fixed Height
+
+ Attribute data-mp-height="…" sets li.mini-projects-grid__item sets a fixed card height in pixels. Text is truncated with an ellipsis if it doesn’t fit.
+
- Сначала выберите тип (stack, horizontal, compact, decor), затем data-mp-cells (1–3) и
+ Сначала выберите тип (stack, horizontal, compact, decor), затем data-mp-cells (1–4) и
порядок li в списке. Карточки с data-mp-extra не влияют на высоту списка до кнопки «Показать все».
Подробности — в docs/mini-projects-cards.md.
@@ -293,35 +330,95 @@
Ширина в клетках
- Атрибут data-mp-cells на li.mini-projects-grid__item задаёт желаемую ширину от 1 до 3 клеток.
+ Атрибут data-mp-cells на li.mini-projects-grid__item задаёт желаемую ширину от 1 до 4 клеток.
Скрипт не даст карточке быть шире текущего ряда.
+
+ Для адаптивной ширины используйте data-mp-cells-sm (при 4 клетках в ряду, ≥ 880 px) и
+ data-mp-cells-xs (при 2 клетках, ≥ 520 px). Скрипт выбирает значение по текущему числу клеток в ряду.
+
+
+
+
+
+
Атрибут
+
Когда применяется
+
Пример
+
+
+
+
+
data-mp-cells
+
Широкий экран (6 клеток, ≥ 1180 px)
+
data-mp-cells="3"
+
+
+
data-mp-cells-sm
+
Средний экран (4 клетки, ≥ 880 px)
+
data-mp-cells-sm="2"
+
+
+
data-mp-cells-xs
+
Узкий экран (2 клетки, ≥ 520 px)
+
data-mp-cells-xs="1"
+
+
+
+
+
+ Пример:data-mp-cells="3" data-mp-cells-sm="2" — карточка занимает 3 клетки на широком экране, 2 на среднем, 1 на узком.
+
-
- 1
- 2
- 3
- 4
- 5
- 6
+
+
+ 123456
+
+
≥ 1180 px — 6 клеток
+
+
3
+
3
+
2
+
2
+
2
+
1
+
2
+
3
+
4
+
2
+
-
Ряд из 6 клеток (широкий экран)
-
-
3
-
3
+
+
+ 1234
+
+
≥ 880 px — 4 клетки
+
+
3
+
1
+
2
+
2
+
1
+
2
+
2
+
4
+
-
-
2
-
2
-
2
-
-
-
1
-
2
-
3
+
+
+ 12
+
+
≥ 520 px — 2 клетки
+
+
1
+
1
+
2
+
2
+
1
+
1
+
- Цифра в блоке = значение data-mp-cells. Карточка занимает столько клеток подряд в одном ряду.
+ Цифра в блоке = значение data-mp-cells. Карточка занимает столько клеток подряд. При узком экране клеток меньше — карточки с шириной > число клеток переносятся на следующий ряд. С data-mp-cells-sm / data-mp-cells-xs карточка меняет ширину при сужении.
+ Атрибут data-mp-height="…" на li.mini-projects-grid__item задаёт фиксированную высоту карточки в пикселях. Текст обрезается с многоточием, если не влезает.
+
+
- {{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
diff --git a/src/pages/cases/mobile-clinic-app.en.html b/src/pages/cases/mobile-clinic-app.en.html
new file mode 100644
index 0000000..234406a
--- /dev/null
+++ b/src/pages/cases/mobile-clinic-app.en.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+ Mobile App for a Network of Clinics — Case Study
+ {{> head-links}}
+
+
+
+
+ {{> header-case}}
+{{> fab-stack}}
+
+
+
UX/UI · Mobile App
+
Mobile App for a Clinic Network
+
+ Booking doctor appointments and managing medical services: a simple user journey, intuitive navigation, and quick access to actions.
+
+
+ The project’s focus is on alleviating patient anxiety and eliminating unnecessary steps: clear appointment statuses, predictable forms, and reliance on familiar mobile app patterns.
+
+
+ A full breakdown of the case study will be published here; for now, this page serves as an entry point from the portfolio section on the homepage.
+
+ Primo RPA Orchestrator: launching robots, managing projects, and monitoring processes in a web dashboard for administrators and automation developers.
+
+
+
+
Context
+
+ In the Primo RPA stack, the orchestrator manages the scheduling, queue, and statuses of RPA robots on the client’s infrastructure. It can be accessed not only from the development studio but also from a browser: users view projects, restart tasks, and troubleshoot failures.
+
+
+ By that point, the product was already in use, but the interface was noticeably outdated: users faced navigation issues, visual clutter in tables, and confusing workflows in some sections. The goal was to refresh the interface’s look, modernize it, and fix the accumulated UX issues.
+
+
+
+
+
Role and Approach
+
+ My role in this project was more of a curatorial one—I didn’t do the design myself, but rather structured the process so that the design and development teams could work in sync.
+
+
+
+ At the same time, I was looking for a designer who could take on the main visual design work. Elizaveta was selected—out of more than 150 applications, she was the best fit for the upcoming workload.
+
+
+ Over the course of three months, I brought the new team member up to speed on the project details: I assigned tasks, provided feedback on the mockups she created, and shared context on user scenarios. Together with the development team, we planned the order in which to update the service’s design—we revised screens based on priority, incorporating changes into ongoing releases, without “stopping everything and redesigning from scratch.”
+
+
+
+
+
Technical Stack and Tools
+
+ The latest version of Ant Design was chosen as the UI framework. It was already in use on the project, and its latest release introduced the Theme Editor—a feature that allowed us to customize the appearance of components using design tokens without manually overriding styles. This provided both designers and developers with a unified language for describing the user interface.
+
+
+ Storybook served as the single source of truth and the bridge between design and development. In Storybook, components weren’t just documented—they actually worked in an isolated environment. This allowed designers to see the elements in action and developers to integrate components into the dashboard more quickly.
+
+
+
+
+
Design System and Synchronization
+
+ To streamline our workflow, we synchronized tokens from Ant Design with those in Figma—colors, padding, typography, and corner radii were standardized to consistent values. Here’s how it worked: if tokens were updated in Figma, they could be exported as JSON and immediately imported into the code without any issues—instead of having a developer manually search for changes and update each token individually. This approach also allowed us to view the design more systematically.
+
+
+
+ Components in Figma were built from scratch—based on Ant Design references, but without using pre-made libraries. The decision to avoid pre-made solutions was a conscious one: we needed full customization to fit our workflow and the ability to use new Figma features that weren’t supported in libraries from the Figma community.
+
+
+ For Storybook, we selected and configured plugins that were relevant to our tasks—accessibility checks, viewing component states, and switching themes and locales. This turned Storybook into more than just a showcase for components; it became a full-fledged design review tool.
+
+
+
+
+
Designer’s Work Environment
+
+ We set up the environment so that designers could see the live implementation of components on their own computers using a local Storybook instance and Git. This allowed for more precise control over how the intended design was translated into code and enabled us to quickly spot discrepancies early on.
+
+
+ We also discussed a step to accelerate Storybook support by allowing designers to write the JSX for implemented components themselves—this shortened the path from mockup to a documented component in Storybook.
+
+
+ We fine-tuned the workflow for adding icons to the library. The key feature was that icons could be displayed in Storybook with filtering and sorting capabilities. At the same time, designers could update icons without involving a developer—they simply pushed new files to the repository.
+
+
+ Being able to see the updated icon immediately in the browser allowed us to optimize it better and eliminate display glitches when using Boolean operations.
+
+
+ We also developed an approach and set of rules for Figma that not only guided the designer but also ensured the creation of consistent and functional icons.
+
+
+
+
+
UX Improvements
+
+ During the interface update, Elizaveta also proposed a number of user experience improvements. Some of them were successfully implemented—for example, clearer grouping of elements in complex forms and redesigned loading states. However, the priority was not to disrupt the existing UX that users had grown accustomed to, so many ideas were saved for the backlog for the next iteration.
+
+
+
+
+
Project List
+
+ The project table is one of the most frequently viewed screens. In the old version, there were many columns, a dense grid, and small text: the project status and name were difficult to read, especially on a laptop.
+
+
In the new version:
+
+
the columns have been reduced to what’s necessary for daily work;
+
statuses and actions have been moved to predictable areas within each row;
+
indentation and typography have been aligned with the rest of the panel using Ant Design.
+
+
+
It used to be
+
+
+
+
+
Now
+
+
+
+
+
+
+
Results and Reflection
+
+ The projects screen became easier to scan: less visual noise, and it’s faster to find the right project and its status. After rolling out the new design to several clients, we received positive feedback and many ideas for further improvements.
+
+
+ I stepped away from the project once Elizaveta felt confident in her role and was able to handle design tasks independently. I occasionally answered specific questions, but overall, the handoff went smoothly.
+
+
+ A little over a year later, the team’s processes have changed significantly. According to the team, tools powered by neural networks—such as Claude Design—are now actively involved in the creation and handoff of designs. What we used to build manually using tokens, Storybook, and environment synchronization is now, to some extent, automated.
+
+
+ I believe the work was completed fairly quickly and to a high standard—amid constant changes in the market and within the company, we managed not only to update the interface but also to establish a sustainable process that continued to function even without my involvement.
+
+ Product concept: setting, assigning, and tracking tasks with an emphasis on process transparency and seamless teamwork.
+
+
+ The goal of the concept is to show how the team views the workflow without “black boxes”: who is working on what, where the bottlenecks are, and which statuses are critical for the next step.
+
+
+ A detailed case study will be posted here later; the page is already linked to a portfolio entry on the website.
+
- {{> projects-section-mp}}{{> projects-section}}
+ {{> projects-related}}{{> projects-section}}
{{> contact-section}}
-{{> footer-spacer}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
+
diff --git a/src/pages/cases/task-tracker.en.html b/src/pages/cases/task-tracker.en.html
new file mode 100644
index 0000000..da01a18
--- /dev/null
+++ b/src/pages/cases/task-tracker.en.html
@@ -0,0 +1,594 @@
+
+
+
+
+
+
+ Task Tracker for a Back-Office System — Case Study
+ {{> head-links}}
+ {{> head-plyr}}
+
+
+
+
+ {{> header-case}}
+{{> fab-stack}}
+
+
+
Test Assignment · Back Office
+
Task Tracker for an Internal Back-Office System
+
Assignment
+
+ Implement an interface for planning and assigning tasks to employees in the operations department who work in a back-office system. There are a large number of diverse tasks that can be grouped into the following categories: Type-1, Type-2, Type-3, Type-4, Type-5, Type-6, Type-7. Each task has its own automatically assigned priority. Users have access only to tasks from specific categories.
+
+
The following needs to be implemented:
+
+
+ Main screen:
+
+
where the user can view all open general tasks and select some for themselves.
+
A personal to-do list displaying both manually selected tasks and automatically assigned ones.
+
For a selected task, there is the option to assign it to another employee or return it to the general list.
+
+
+
A screen where users can see what tasks each person is working on.
+
+
+
Introduction
+
+ Since I personally have extensive experience working with task trackers, I’m drawing on my own experience and that of my colleagues, while taking the project requirements into account.
+
+
+
References
+
+
+
+
+
Task trackers (Trello, Monday, YouTrack, etc.)
+
Services for visual database management (Notion, Airtable, etc.)
On the All Tasks tab, the employee can view all available tasks.
+
+
+
+
+ The "My tasks" tab displays only those tasks assigned to the current employee. Tasks are grouped by status (the status is hidden in the task card).
+
+
+
+
+
The “Task by Assignee” tab displays all tasks broken down by assignee.
+
+
+
+
Options for the current tab are located on the right-hand side.
+
+
+
+
+ All changes are saved when you leave the tab and when you close the entire Tasks page. These settings are individual, and each employee can customize the page as they see fit.
+
+
+
Group—allows you to create additional groupings based on task properties (category, priority, assignee, status, creation date, modification date).
+
View—allows you to change the display of current tasks to a different view: board, list, or table.
+
Filter—allows you to hide tasks based on their properties.
+
+ Sort—allows you to determine the order in which tasks are displayed based on their properties. If sorting by status (High, Medium, Low) is enabled, you can choose either alphabetical order or reverse order, or you can create your own order. Dates and numbers are sorted in ascending or descending order.
+
+
+ Search on page — allows you to display only those tasks whose title or description contains a specific search word or phrase. (Search functionality can be improved by adding search operators if research demonstrates the need.)
+
+
+
+ The page uses lazy loading for tasks. As you scroll down, new tasks will load. The number of tasks is displayed in the headers; this can be helpful for estimating how far you need to scroll to see everything. There may be a large number of tasks, in which case it might make sense to add pagination as well.
+
+
+
Display Options (Views)
+
+ Different views of the same tasks can be useful for various contexts in which the Tasks page is used, as well as to accommodate employees’ personal preferences, which increases their efficiency. An employee who isn’t annoyed by the interface works more productively and contributes more.
+
+
+
+
+
+
+ The Board view makes it easier to use the drag-and-drop feature (see below). It’s also visually easier to determine the number of tasks in a category, which I imagine could be helpful. When tasks are grouped by completion status, moving cards from left to right creates a natural horizontal flow of tasks from point A to point B.
+
+
+
+
+
+ The List view allows you to see more information in a smaller screen area. At the same time, this view is visually and functionally simpler than the table view, which means less cognitive load on the user. Also, all task titles start at the far left, making it easy to scan the list.
+
+
+
+
+
+ The Table view allows you to work with all task fields immediately, without having to open the task in a modal window or a separate page. The table allows you to customize the width and order of columns, which can be convenient in certain use cases.
+
+ In all views, you can drag and drop elements (cards, list items, and table rows and columns). This promotes more productive work and feels natural to the user. For example, if you need to distribute tasks among employees (for an admin or manager), change the status of multiple tasks at once, or do so within a short period of time.
+
+ A schematic representation of how Drag & Drop works. This type of prototype helps designers better understand the nuances that need to be considered in the user flow, as well as when preparing specifications for developers. Also, by testing this prototype, you may realize that the feature needs to work completely differently and decide to redesign it right away. This saves overall development time.
+
+ In the prototype, only the blue card can be moved, and not all positions work correctly. You can view the recording, which is available on the same prototype page.
+
+
+
Style
+
+ The visual style can be anything you like without changing the framework itself. For example, it could be a bit more colorful with more expressive fonts.
+
+ During the Figma prototyping phase, you can conduct interviews with several employees—for example, 5 people. First, add real tasks from the current system to the prototype; about 20 should be enough. Next, you can hold a video call with the respondent’s screen shared or meet in person to present a few tasks and ask some questions. During the session, the respondent can interact with the prototype, but if something doesn’t work, the interviewer can note the attempt and explain that this feature isn’t working yet, as well as ask what the respondent expects to see next.
+
+
Tasks
+
+
Find your current tasks on the Tasks page under the All Tasks tab
+
Assign tasks to yourself to work on
+
Find a task related to a specific topic (the interviewer provides an example) that they have already completed
+
Show tasks that belong only to a specific category and must be completed by the end of the week
+
Request that the priority of several tasks be changed at once
+
Rate the visual appeal of the interface on a scale of 1 to 10
+
Rate your overall impression of the proposed concept on a scale of 1 to 10
+
Suggest additional features that would be useful
+
+
Analytics
+
To assess the relevance of the implemented features, the following metrics must be collected.
+
+
+ Number of page views for the "Tasks" page page_views_tasks with the option to break them down
+
+
+ by tabs
+
+
All tasks all_tasks_tab
+
My tasks my_tasks_tab
+
Tasks by Assignment tasks_by_assign
+
+
+
+ by role
+
+
Admin admin
+
Manager manager
+
User user
+
+
+
+ by search attempts
+
+
Search Used search used
+
+
+
+
+
+ Number of enabled custom settings custom_settings with the option to group
+
+
+ by tabs
+
+
All tasks all_tasks_tab
+
My tasks my_tasks_tab
+
Tasks by Assignment tasks_by_assign
+
+
+
+ by role
+
+
Admin admin
+
Manager manager
+
User user
+
+
+
+ by type
+
+
Group group
+
Filter filter
+
Sort sort
+
+
+
+
+
+ Number of included species View Usedview_used with the option to break down
+
+
+ by tabs
+
+
All tasks all_tasks_tab
+
My tasks my_tasks_tab
+
Tasks by Assignment tasks_by_assign
+
+
+
+ by role
+
+
Admin admin
+
Manager manager
+
User user
+
+
+
+
+
+
+ Then, over the next six months (or sooner, depending on user activity), we’ll be able to see the results and conduct a study (in the form of interviews) on the features that are used least frequently and those that have received complaints, in order to gather high-quality data and get a complete picture of what’s happening.
+
+
+
Additional details
+
+ When creating mockups in Figma, the following were used: auto-layout features; interactive components; plugins (Iconify, Fitter, Protofly, Shadow Maker, Dark Side, etc.); and the Community file (+40 Types of Cursor). About 60% of the layers are named and organized for easier workflow. An 8pt grid is used, with additional sizes of 2pt and 4pt. Color styles are available for light and dark themes. The design adapts to changing window sizes (no mobile version).
+
На вкладке All tasks сотрудник может ознакомиться со всеми доступными задачами.
-
+
На вкладке My tasks отображаются только те задачи, что закреплены за текущим сотрудником. Группировка идёт по статусам задач (статус в карточке задачи скрыт).
-
+
На вкладке Task by assign отображены все задачи c разбивкой по исполнителю.
-
+
В правой части имеются опции для текущей вкладки.
-
+
Все изменения сохраняются после ухода с вкладки и после закрытия всей страницы Tasks. Эти настройки индивидуальны и каждый сотрудник может настроить страницу как ему удобно.
@@ -212,7 +212,7 @@
Разные виды отображения одних и тех же задач могут быть полезны для разных контекстов использования страницы Tasks и также в угоду личным предпочтениям сотрудника, что повышает его эффективность. Сотрудник, которого не раздражает интерфейс работает продуктивнее и приносит больше пользы.