i18n: build-time ru/en localization + EN homepage & cookie-policy

- build-pages.mjs: per-locale page/partial overrides (*.en.html), i18n token
  resolution with ru fallback, <html lang> + assetPrefix per locale
- UI dictionaries src/i18n/{ru,en}.json; lang-switch partial
- headers/footer/partials converted to {{i18n:…}} tokens
- root-absolute static asset refs so /en/ subpath loads css/js/vendor
- EN content: homepage (index.en.html + partials) and cookie-policy.en.html
  (cookie-policy body translated via apinex.bond /v1/tools/translator)
This commit is contained in:
root committed 2026-09-15 22:27:51 +02:00
1 parent a78d431d3c
commit a0dda98b83
79 files changed
+22841 -422

No files matched your search

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

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+36 -17
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -181,9 +181,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -371,7 +390,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+36 -17
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -206,9 +206,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1025,7 +1044,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -216,9 +216,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1155,7 +1174,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+36 -17
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -206,9 +206,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1189,7 +1208,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+36 -17
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -272,9 +272,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -763,7 +782,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -449,9 +449,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<nav class="nav" aria-label="Разделы страницы">
<a href="../../">На главную</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1076,7 +1095,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -197,9 +197,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -977,7 +996,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -174,9 +174,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -719,7 +738,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -390,9 +390,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<nav class="nav" aria-label="Разделы страницы">
<a href="../../">На главную</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1233,7 +1252,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -174,9 +174,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -719,7 +738,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -181,9 +181,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -753,7 +772,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -193,9 +193,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -858,7 +877,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+36 -17
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -181,9 +181,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -371,7 +390,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -174,9 +174,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -719,7 +738,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+36 -17
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,20 +89,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -196,9 +196,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1147,7 +1166,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+36 -17
View File
@@ -8,9 +8,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../../assets/fonts/merriweather.css",
geologica: "../../assets/fonts/geologica.css",
mono: "../../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -86,20 +86,20 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../../assets/favicon/site.webmanifest" />
<link rel="preload" href="../../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../../css/main.css" as="style" />
<link rel="preload" href="../../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../../js/main.js" />
<link rel="stylesheet" href="../../css/main.css" />
<script src="../../js/skill-connectors.js" defer></script>
<script type="module" src="../../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="../../vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
@@ -179,9 +179,28 @@
<div class="header__inner">
<a class="logo" href="../../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<nav class="nav" aria-label="Разделы страницы">
<a href="../../">На главную</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1111,7 +1130,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../assets/fonts/merriweather.css",
geologica: "../assets/fonts/geologica.css",
mono: "../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../assets/favicon/site.webmanifest" />
<link rel="preload" href="../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../css/main.css" as="style" />
<link rel="preload" href="../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../js/main.js" />
<link rel="stylesheet" href="../css/main.css" />
<script src="../js/skill-connectors.js" defer></script>
<script type="module" src="../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -153,9 +153,28 @@
<div class="header__inner">
<a class="logo" href="../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<nav class="nav" aria-label="Разделы страницы">
<a href="../">На главную</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -319,7 +338,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../cookie-policy/">Политика cookie</a></span>
</div>
+46
View File
@@ -563,6 +563,42 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover {
margin-left: 0;
}
/* Переключатель языка в шапке: компактные pill-кнопки RU/EN */
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.15rem;
margin-left: 0.35rem;
font-size: 0.8rem;
}
.lang-switch__btn {
appearance: none;
border: 1px solid var(--v12-glass-neutral-border);
background: var(--v12-glass-neutral-bg);
color: var(--text-muted);
border-radius: 999px;
padding: 0.22rem 0.55rem;
font: inherit;
font-weight: 600;
line-height: 1;
cursor: pointer;
text-decoration: none;
transition: color 0.15s ease, border-color 0.15s ease, background 0.2s ease;
}
.lang-switch__btn:hover,
.lang-switch__btn:focus-visible {
color: var(--text);
border-color: color-mix(in srgb, var(--accent) 45%, var(--v12-glass-neutral-border));
}
.lang-switch__btn[aria-current="true"] {
color: var(--on-accent);
background: var(--accent);
border-color: var(--accent);
}
.header__cluster {
display: flex;
flex-wrap: nowrap;
@@ -2467,6 +2503,9 @@ html[data-theme="dark"] {
display: inline-flex;
align-items: center;
justify-content: center;
/* Позиционируем, чтобы z-index работал: наведённая кнопка должна
рисоваться поверх соседа и показывать свой бордер целиком (включая шов). */
position: relative;
}
/* Соседняя кнопка сдвигается на 1px, перекрывая разделительный бордер */
@@ -2492,6 +2531,13 @@ html[data-theme="dark"] {
border-radius: 999px;
}
/* Наведённая/в фокусе кнопка поднимается над соседом: её бордер (включая
шовный 1px, перекрытый соседом из-за margin-left:-1px) рисуется целиком. */
.case-image-viewer__btn--nav:hover,
.case-image-viewer__btn--nav:focus-visible {
z-index: 1;
}
/* Точки-пагинация в тулбаре просмотрщика */
.case-image-viewer__dots {
display: flex;
@@ -0,0 +1,423 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: AI assistant — плагин для Figma с AI-функциями."
/>
<title>AI assistant — Плагин для Figma — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="ai-assistant">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Figma · Plugin</p>
<h1 class="case-page__h1">AI assistant</h1>
<p class="case-page__sub">Плагин для Figma с AI-функциями</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Контекст</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Задача</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Решение</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h2 id="result">Результат</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">Плагин для Figma с AI-функциями.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Плагин для колоночного отображения карточек в Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Разработка сайта с использованием AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,815 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: Card Columns, плагин-вид для Obsidian Bases. Показывает заметки как карточки, разложенные по колонкам, с фильтрацией тегов по И/ИЛИ."
/>
<title>Card Columns, плагин для Obsidian Bases: кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-article .case-brief {
list-style: none;
margin: 1.5rem 0 0;
padding: 1.1rem 1.25rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.85rem 1.5rem;
font-size: 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-md, 12px);
}
.case-article .case-brief li {
margin: 0;
}
.case-article .case-brief span {
display: block;
margin-bottom: 0.15rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.case-article .case-todo {
margin: 1rem 0 1.25rem;
padding: 0.9rem 1.1rem;
border-left: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 var(--radius-sm, 8px) var(--radius-sm, 8px) 0;
font-size: 0.92rem;
}
.case-article .case-todo p {
margin: 0 0 0.5rem;
}
.case-article .case-todo p:last-child {
margin-bottom: 0;
}
.case-article .case-todo__q {
font-weight: 600;
color: var(--accent);
}
.case-todo__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.85;
}
.case-article details.case-details {
margin: 0.75rem 0 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-sm, 8px);
}
.case-article details.case-details > summary {
cursor: pointer;
font-weight: 600;
color: var(--accent);
padding: 0.25rem 0;
}
.case-article details.case-details[open] > summary {
margin-bottom: 0.5rem;
}
.case-block {
margin-top: 3rem;
padding-top: 2.5rem;
border-top: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.case-block:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="card-columns">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian Bases · Plugin</p>
<h1 class="case-page__h1">
Канбан-вид для Obsidian Bases,<br />который не&nbsp;трогает ваши заметки
</h1>
<p class="case-page__sub">
Card Columns: заметки как карточки по колонкам,<br />
отбор по тегам И/ИЛИ за два клика
</p>
<!-- МЕДИА: hero-спойлер, финальный экран плагина. Раскомментировать при вставке скрина.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/hero.webp" alt="Card Columns: колонки карточек поверх .base файла" width="1920" height="1080" loading="eager" decoding="async" data-image-viewer />
<figcaption>Вид Columns поверх базы заметок</figcaption>
</figure>
-->
<ul class="case-brief">
<li><span>Роль</span>Продуктовый дизайн и разработка</li>
<li><span>Платформа</span>Obsidian Bases</li>
<li><span>Статус</span>Рабочий плагин, open source (MIT)</li>
<li><span>Команда</span>Соло, пет-проект</li>
<li><span>Срок</span>Черновой ответ, заменить</li>
</ul>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных?
</p>
<p>
Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу
собственной работы. Заменить на реальное.
</p>
</div>
<p class="section__prose">
В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а
в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он
плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их
раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns.
</p>
<div class="case-block" id="kontekst">
<h2>Контекст</h2>
<p class="section__prose">
Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь
заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть
встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет
нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель
Bases наравне со штатными.
</p>
<p class="section__prose">
Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что
выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика
выборки остаётся на Bases, а моя задача, только представление и навигация по нему.
</p>
<!-- МЕДИА: общий скрин/видео обзора — доска колонок поверх .base файла.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/overview.webp" alt="Вид Card Columns поверх .base файла: заметки по колонкам" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Обзор вида</figcaption>
</figure>
-->
</div>
<div class="case-block" id="pochemu-svoi">
<h2>Почему не готовое</h2>
<p class="section__prose">
Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо:
</p>
<p class="section__prose">
<strong>Штатный вид карточек Bases.</strong> Рисует ровный поток карточек без группировки.
Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки.
Отпал первым.
</p>
<p class="section__prose">
<strong>Канбан-плагины из комьюнити.</strong> Большинство тянет за собой свою систему
хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база,
которую надо синхронизировать. Мне это было не нужно.
</p>
<p class="section__prose">
<strong>Соседний bases-kanban.</strong> Ближайший по духу: цепляется к Bases, делает
канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина
это риск, о котором ниже.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
Отказ от drag-and-drop, это не недоделка, а позиция. Плагин представления не должен
писать в пользовательские данные. Это решение определило всё остальное.
</p>
</div>
</aside>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие
именно смотрел?
</p>
<p>
Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban
смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать.
</p>
</div>
</div>
<div class="case-block" id="zadacha">
<h2>Задача</h2>
<p class="section__prose">
Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет
быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит
своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны
работать как раньше.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
Главный риск, сделать вид, который конфликтует со штатным тулбаром Bases. Тогда
пользователь теряет сортировку и поиск, ради которых Bases и берут. Этого нельзя
было допустить.
</p>
</div>
</aside>
</div>
<div class="case-block" id="reshenie">
<h2>Решения</h2>
<p class="section__prose">
Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса,
группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов,
которые определили результат. В каждом есть то, от чего я отказался.
</p>
<!-- МЕДИА: скриншот вида с колонками и карточками внутри.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/board.webp" alt="Колонки карточек с группировкой по свойству" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Вид с колонками</figcaption>
</figure>
-->
<h3>Группировка по любому свойству</h3>
<p class="section__prose">
Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому
frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна
и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по
приоритету.
</p>
<h3>Фильтр тегов по И и ИЛИ</h3>
<p class="section__prose">
Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они
отсеивают карточки по всем колонкам сразу:
</p>
<ul class="section__prose">
<li>Левый клик по тегу, показать только его (сбрасывает остальные фильтры).</li>
<li>Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).</li>
<li>Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.</li>
<li>Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.</li>
<li>All, снять все фильтры.</li>
</ul>
<p class="section__prose">
Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной:
быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку,
отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано.
</p>
<p class="section__prose">
Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо
отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы
с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил.
Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда.
</p>
<!-- МЕДИА: короткое видео фильтрации по тегам (OR / AND).
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../assets/card-columns-obsidian/filter-poster.webp" data-case-video-player style="display: block; width: 100%; height: auto; margin: 0">
<source src="../assets/card-columns-obsidian/filter.webm" type="video/webm" />
<source src="../assets/card-columns-obsidian/filter.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
</div>
<figcaption>Фильтрация по тегам: OR и AND</figcaption>
</figure>
-->
<h3>Карточки и обложки</h3>
<p class="section__prose">
По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно
показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки
полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание
(cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего).
</p>
<h3>Решение: настройки вынесены из кода</h3>
<p class="section__prose">
Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать
для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид
подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров.
</p>
<details class="case-details">
<summary>Показать полный список настроек</summary>
<p class="section__prose"><strong>General</strong></p>
<ul class="section__prose">
<li>Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.</li>
<li>Ширина карточки (px).</li>
<li>Колонок в группе (1–6), сетка внутри колонки.</li>
<li>Зебра колонок (чередование фона для сканирования).</li>
<li>Masonry-раскладка (карточки заполняют вертикальные пустоты).</li>
</ul>
<p class="section__prose"><strong>Title</strong></p>
<ul class="section__prose">
<li>Перенос заголовка, жирный заголовок, размер шрифта (px).</li>
</ul>
<p class="section__prose"><strong>Properties</strong></p>
<ul class="section__prose">
<li>Раскладка чипов: стек (вертикально) или сетка (компактно в строку).</li>
<li>Перенос многострочных значений, размер шрифта.</li>
<li>Формат даты и даты-времени + локаль (Moment.js).</li>
</ul>
<p class="section__prose"><strong>Cover</strong></p>
<ul class="section__prose">
<li>Источник: нет / первая картинка в заметке / свойство-обложка.</li>
<li>Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.</li>
</ul>
</details>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса
в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно.
</p>
<p>
Черновой ответ: да, первая версия была под мою базу, настройки появились, когда
понадобилось открыть плагин наружу. Проверить и поправить.
</p>
</div>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
Сплит-превью сделал как в самом Obsidian: левый клик переиспользует панель, Ctrl+Клик,
новую вкладку внутри сплита. Без этого на широком экране карточки неудобно
просматривать рядом с доской.
</p>
</div>
</aside>
<h3>Работает внутри штатного тулбара</h3>
<p class="section__prose">
Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из
тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не
дублируются и не зависят от отдельного хранилища.
</p>
<!-- МЕДИА: скриншот настроек (gear-меню) с секциями.
<figure class="case-media case-media--content">
<img src="../assets/card-columns-obsidian/settings.webp" alt="Окно настроек плагина: General, Title, Properties, Cover" width="1200" height="675" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Настройки вида</figcaption>
</figure>
-->
</div>
<div class="case-block" id="rezultat">
<h2>Результат и как я это мерял</h2>
<p class="section__prose">
Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента
карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному
виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не
ломают.
</p>
<h3>Замер: сколько действий на отбор</h3>
<p class="section__prose">
Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную
карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В
Columns, два клика по пилюлям и колонка перед глазами.
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный
вид против Columns. Или посчитать точное число действий.
</p>
<p>
Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и
требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные,
заменить на замеренные.
</p>
</div>
<h3>Замер: живучесть в ежедневной работе</h3>
<p class="section__prose">
Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я
пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует
вспоминать, как им управлять.
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая
цифра, подтверждающая, что плагином пользуются не только ты.
</p>
<p>
Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так
что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда,
это сильнее любого-self-report.
</p>
</div>
<p class="section__prose">
Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно,
это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных.
Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А
пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что
мешает им пользоваться сходу.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
Главный вывод: вид для Bases получается рабочим только тогда, когда он уважает
тулбар фреймворка. Я добавил представление, а не вторую систему хранения, и заметки
остались обычными заметками.
</p>
</div>
</aside>
</div>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">Плагин для Figma с AI-функциями.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Плагин для колоночного отображения карточек в Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Разработка сайта с использованием AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+763
View File
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: редизайн и развитие интерфейса игрового движка MANU — визуальная система и сложные пользовательские сценарии."
/>
<title>Игровой движок MANU — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="manu-game-maker">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Product Design · Desktop App</p>
<h1 class="case-page__h1">Игровой движок MANU</h1>
<p class="case-page__sub">
Редизайн и развитие интерфейса: новая концепция работы, обновлённая визуальная система и сотни проработанных пользовательских сценариев.
</p>
<p class="section__prose">
Работа велась вокруг десктопного редактора и связанных панелей: нужно было удержать целостность визуального языка при росте функциональности и не потерять опытные сценарии силовых пользователей.
</p>
<p class="section__prose">
Развёрнутый лонгрид кейса появится на этой странице позже; ниже — обложка для навигации из портфолио.
</p>
<h2>Обложка</h2>
<figure class="case-media case-media--wide">
<img
src="../../../assets/manu-game-maker/cover.webp"
alt="Интерфейс игрового движка MANU — обложка кейса"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Текущее превью для карточки портфолио</figcaption>
</figure>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: мобильное приложение для сети клиник — запись к врачам, медицинские услуги, навигация и быстрые действия."
/>
<title>Мобильное приложение для сети клиник — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="mobile-clinic-app">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">UX/UI · Mobile App</p>
<h1 class="case-page__h1">Мобильное приложение для сети клиник</h1>
<p class="case-page__sub">
Запись к врачам и работа с медицинскими услугами: простой путь пользователя, понятная навигация и быстрый доступ к действиям.
</p>
<p class="section__prose">
Фокус проекта — снять тревожность и лишние шаги у пациента: ясные статусы записи, предсказуемые формы и опора на привычные паттерны мобильных приложений.
</p>
<p class="section__prose">
Полный разбор кейса будет опубликован здесь; пока страница служит точкой входа из блока портфолио на главной.
</p>
<h2>Обложка</h2>
<figure class="case-media case-media--wide">
<img
src="../../../assets/mobile-clinic-app/cover.webp"
alt="Мобильное приложение для сети клиник — обложка кейса"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Текущее превью для карточки портфолио</figcaption>
</figure>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
@@ -0,0 +1,790 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: плагин колоночного вида для Obsidian — дизайн и UX плагина для заметок."
/>
<title>Колоночный вид для Obsidian — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body>
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian · Plugin</p>
<h1 class="case-page__h1">Колоночный вид — плагин для Obsidian</h1>
<p class="case-page__sub">Дизайн и разработка интерфейса для многоколоночной работы с заметками</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Контекст</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Задача</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Решение</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h3>Фильтр использования</h3>
<figure class="case-media">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../../../assets/obsidian-plugin-columns-view/filter use.webp" data-case-video-player style="display: block; width: 100%; height: auto">
<source src="../../../assets/obsidian-plugin-columns-view/filter use.webm" type="video/webm" />
<source src="../../../assets/obsidian-plugin-columns-view/filter use.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
</div>
<figcaption>Фильтрация заметок в колоночном виде.</figcaption>
</figure>
<h3>Конфигурация отображения</h3>
<figure class="case-media">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../../../assets/obsidian-plugin-columns-view/view configure.webp" data-case-video-player style="display: block; width: 100%; height: auto">
<source src="../../../assets/obsidian-plugin-columns-view/view configure.webm" type="video/webm" />
<source src="../../../assets/obsidian-plugin-columns-view/view configure.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
</div>
<figcaption>Настройка колонок и отображения.</figcaption>
</figure>
<h2 id="result">Результат</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
</body>
</html>
@@ -0,0 +1,901 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: редизайн веб-панели оркестратора Primo RPA — кураторство дизайна, внедрение Ant Design и Storybook, синхронизация токенов с Figma."
/>
<title>Веб-панель для оркестрации автоматизаций — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
margin: 1.25rem 0 0.5rem;
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="orc-automation-panel">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Primo RPA · UX/UI · Web App</p>
<h1 class="case-page__h1">Веб-панель для оркестрации автоматизаций</h1>
<p class="case-page__sub">
Оркестратор Primo RPA: запуск роботов, проекты и контроль процессов в веб-кабинете для админов и разработчиков автоматизаций.
</p>
<div class="case-block" id="obzor">
<h2>Контекст</h2>
<p class="section__prose">
В стеке Primo RPA оркестратор отвечает за расписание, очередь и статусы RPA-роботов на инфраструктуре клиента. С ним работают не только из студии разработки, но и из браузера: смотрят проекты, перезапускают задания, разбирают сбои.
</p>
<p class="section__prose">
К тому моменту продукт уже был в эксплуатации, но интерфейс заметно устарел: пользователи сталкивались с неудобствами в навигации, визуальным шумом в таблицах и неочевидными сценариями в некоторых разделах. Задача стояла освежить вид интерфейса, сделать его современным и исправить накопившиеся UX-проблемы.
</p>
</div>
<div class="case-block" id="rol">
<h2>Роль и подход</h2>
<p class="section__prose">
Моя роль в этом проекте была скорее кураторской — я не делал дизайн самостоятельно, а выстраивал процесс так, чтобы команда дизайна и разработки работала слаженно.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>Я изучил продукт «с нуля»: разобрался с интерфейсом оркестратора, нашёл болевые точки пользователей и сформулировал задачи для обновления.</p>
</div>
</aside>
<p class="section__prose">
Параллельно я искал дизайнера, который взял бы на себя основную визуальную работу. Отбор прошла Елизавета — среди более чем 150 заявок она лучше всего подходила под предстоящий фронт работ.
</p>
<p class="section__prose">
В течение трёх месяцев я погружал новую сотрудницу в детали проекта: ставил задачи, давал обратную связь по создаваемым макетам, делился контекстом по пользовательским сценариям. Вместе с командой разработки мы планировали порядок обновления дизайна сервиса — меняли экраны по приоритету, вкладывая изменения в текущие релизы, без «остановить всё и перерисовать».
</p>
</div>
<div class="case-block" id="tekhnicheskiy-stek">
<h2>Технический стек и инструменты</h2>
<p class="section__prose">
В качестве фреймворка для интерфейса был выбран <a href="https://ant.design/" target="_blank" rel="noopener noreferrer">Ant Design</a> последней версии. Он уже использовался в проекте, и его свежий релиз предлагал <a href="https://ant.design/theme-editor" target="_blank" rel="noopener noreferrer">Theme Editor</a> — возможность настраивать внешний вид компонентов через дизайн-токены без переопределения стилей вручную. Это давало и дизайнерам, и разработчикам единый язык описания интерфейса.
</p>
<p class="section__prose">
Единым источником правды и мостом между дизайном и разработкой стал <a href="https://storybook.js.org/" target="_blank" rel="noopener noreferrer">Storybook</a>. В нём компоненты были не просто задокументированы, а реально работали в изолированной среде — это позволяло дизайнерам видеть реализацию элементов вживую, а разработчикам — быстрее интегрировать компоненты в панель.
</p>
</div>
<div class="case-block" id="dizayn-sistema">
<h2>Дизайн-система и синхронизация</h2>
<p class="section__prose">
Для удобства работы мы синхронизировали токены из Ant Design с токенами в Figma — цвета, отступы, типографика и радиусы скругления были приведены к единым значениям. Работало это так: если в Figma токены обновлялись, их можно было выгрузить в JSON и без проблем сразу загрузить в код — вместо того, чтобы разработчик вручную искал изменения и правил каждый токен индивидуально. Такой подход также позволил смотреть на дизайн более системно.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>Основная задача, которую решал подход такого тесного сотрудничества дизайна и разработки, — максимально точно перенести задуманное в прод и не растерять по пути мелочей.</p>
</div>
</aside>
<p class="section__prose">
Компоненты в Figma создавались с нуля — по референсам Ant Design, но без использования готовых библиотек. Отказ от готовых решений был осознанным: нам нужна была полная настройка под наш стиль работы и возможность использовать новые функции Figma, которые не поддерживались в библиотеках из сообщества Figma.
</p>
<p class="section__prose">
Для Storybook мы отобрали и настроили плагины, которые были уместны для наших задач — контроль доступности, просмотр состояний компонентов, переключение тем и локалей. Это сделало Storybook не просто витриной компонентов, а полноценным инструментом дизайн-ревью.
</p>
</div>
<div class="case-block" id="rabochaya-sreda">
<h2>Рабочая среда дизайнера</h2>
<p class="section__prose">
Мы настроили среду так, чтобы дизайнер мог видеть реализацию компонентов вживую у себя на компьютере, используя локальный Storybook и Git. Это позволяло точнее контролировать, как задуманный дизайн воплощается в коде, и оперативно замечать расхождения на ранних этапах.
</p>
<p class="section__prose">
Обсуждался также шаг ускорить поддержку Storybook тем, чтобы дизайнер сам мог описывать JSX для реализованного компонента — это сокращало путь от макета до документированного компонента в сторибуке.
</p>
<p class="section__prose">
Мы отладили рабочий процесс по передаче иконок в библиотеку. Особенность заключалась в том, чтобы иконки можно было отобразить в Storybook с фильтрацией и сортировкой. При этом дизайнер мог обновлять иконки без привлечения разработчика — просто пушил новые файлы в репозиторий.
</p>
<p class="section__prose">
Увидеть обновлённую иконку сразу в браузере позволяло лучше её оптимизировать и убрать косяки отображения при использовании булевых операций.
</p>
<p class="section__prose">
Для Figma также был разработан подход и правила, которые не только ограничивали дизайнера, но и позволяли гарантированно делать консистентные и рабочие иконки.
</p>
</div>
<div class="case-block" id="ux-uluchsheniya">
<h2>UX-улучшения</h2>
<p class="section__prose">
За время обновления интерфейса Елизавета предложила ещё и ряд улучшений пользовательского опыта. Часть из них была успешно внедрена — например, более понятная группировка элементов в сложных формах и переработанные состояния загрузки. Но в приоритете было не ломать старый UX, к которому привыкли пользователи, поэтому многие идеи сохранились в бэклог для следующей итерации.
</p>
</div>
<div class="case-block" id="proekty">
<h2>Список проектов</h2>
<p class="section__prose">
Таблица проектов — один из самых частых экранов. На старой версии много колонок, плотная сетка и мелкий текст: статус и имя проекта читаются с трудом, особенно на ноутбуке.
</p>
<p class="section__prose">В новой версии:</p>
<ul>
<li>колонки свели к тому, что нужно для ежедневной работы;</li>
<li>статусы и действия вынесли в предсказуемые зоны строки;</li>
<li>отступы и типографику подтянули под остальную панель на Ant Design.</li>
</ul>
<h3>Было</h3>
<figure class="case-media case-media--wide">
<img
src="../../../assets/orc-primo-rpa/projects--table-old.webp"
alt="Список проектов в оркестраторе — предыдущая версия таблицы"
width="1919"
height="867"
loading="lazy"
decoding="async"
data-image-viewer
/>
</figure>
<h3>Стало</h3>
<figure class="case-media case-media--wide">
<img
src="../../../assets/orc-primo-rpa/projects--table-new.webp"
alt="Список проектов в оркестраторе — обновлённая таблица"
width="1366"
height="768"
loading="lazy"
decoding="async"
data-image-viewer
/>
</figure>
</div>
<div class="case-block" id="itog">
<h2>Результаты и рефлексия</h2>
<p class="section__prose">
Экран проектов стал проще сканировать: меньше визуального шума, быстрее находить нужный проект и его состояние. После раскатывания нового дизайна среди нескольких клиентов мы получили положительные отзывы и множество идей для дальнейших улучшений.
</p>
<p class="section__prose">
Я оставил проект, когда Елизавета уже уверенно чувствовала себя в нём и могла самостоятельно вести дизайнерские задачи. Периодически я отвечал на точечные вопросы, но в целом передача прошла гладко.
</p>
<p class="section__prose">
Спустя год с небольшим процессы в команде заметно изменились. По словам ребят, в создании и передаче дизайна теперь активное участие принимают инструменты с нейросетями — такие как Claude Design. То, что мы начинали выстраивать вручную через токены, Storybook и синхронизацию сред, сейчас в какой-то мере автоматизируется.
</p>
<p class="section__prose">
Считаю, что проделанная работа была выполнена достаточно оперативно и качественно — в условиях постоянных изменений на рынке и внутри компании нам удалось не просто обновить интерфейс, но и выстроить устойчивый процесс, который продолжил работать уже без моего участия.
</p>
</div>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
+423
View File
@@ -0,0 +1,423 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: Этот сайт — разработка с использованием AI IDE."
/>
<title>Этот сайт — Разработка с AI IDE — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article h3 {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="site-with-ai-ide">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Разработка · AI IDE</p>
<h1 class="case-page__h1">Этот сайт</h1>
<p class="case-page__sub">Разработка сайта с использованием AI IDE</p>
<p class="section__prose">
<!-- Здесь будет введение -->
</p>
<h2 id="context">Контекст</h2>
<p class="section__prose">
<!-- Контекст проекта -->
</p>
<h2 id="task">Задача</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<h2 id="solution">Решение</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<h2 id="result">Результат</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-assistant">
<a
class="mp-card mp-card--link mp-card--compact"
href="../ai-assistant-figma/"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">Плагин для Figma с AI-функциями.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="card-columns">
<a
class="mp-card mp-card--link mp-card--compact"
href="../card-columns-obsidian/"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Плагин для колоночного отображения карточек в Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="site-with-ai-ide">
<a
class="mp-card mp-card--link mp-card--compact"
href="../site-with-ai-ide/"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Разработка сайта с использованием AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium" data-font="merriweather">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: концепт сервиса распределения задач — прозрачность процессов и командная работа."
/>
<title>Концепт сервиса распределения задач — кейс</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.case-article {
max-width: 48rem;
margin: 0 auto;
}
.case-article h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.case-article .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article a:not(.btn) {
color: var(--accent);
}
</style>
<script>
(function () {
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f === "merriweather" || f === "mono" || f === "geologica") {
d.dataset.font = f;
} else {
d.dataset.font = "merriweather";
}
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
} catch (e) {}
})();
</script>
</head>
<body data-current-case="task-distribution-concept">
<a class="skip-link" href="#case-main">Skip to case content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="#contact">Contact</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">UX/UI · Analytics</p>
<h1 class="case-page__h1">Концепт сервиса распределения задач</h1>
<p class="case-page__sub">
Продуктовый концепт: постановка, распределение и отслеживание задач с акцентом на прозрачность процессов и удобство командной работы.
</p>
<p class="section__prose">
Задача концепта — показать, как команда видит поток работ без «чёрных ящиков»: кто что взял, где узкие места и какие статусы критичны для следующего шага.
</p>
<p class="section__prose">
Детальный кейс появится здесь позже; страница уже связана с карточкой в портфолио на сайте.
</p>
<h2>Обложка</h2>
<figure class="case-media case-media--wide">
<img
src="../../../assets/task-distribution-concept/cover.webp"
alt="Концепт сервиса распределения задач — обложка кейса"
width="1200"
height="675"
loading="lazy"
decoding="async"
data-image-viewer
/>
<figcaption>Текущее превью для карточки портфолио</figcaption>
</figure>
</div>
</main>
<section class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Смотрите также</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="../ai-platform-primo-rpa/" aria-label="Платформа для работы с AI — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="../lunacy/" aria-label="Графический редактор Lunacy — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../manu-game-maker/" aria-label="Игровой движок MANU — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Игровой движок MANU</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Редизайн и развитие интерфейса игрового движка: новая концепция работы, обновлённая визуальная система и проработка сотен сложных пользовательских сценариев.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../mobile-clinic-app/" aria-label="Мобильное приложение для сети клиник — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Мобильное приложение для сети клиник</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Дизайн мобильного приложения для записи к врачам и работы с медицинскими услугами. Фокус на простом пользовательском пути, понятной навигации и быстром доступе к нужным действиям.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="../orc-automation-panel/" aria-label="Веб-панель для оркестрации автоматизаций — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Менторство и наставничество</li>
</ul>
<p class="mp-card__desc">Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-distribution-concept/" aria-label="Концепт сервиса распределения задач — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Концепт сервиса распределения задач</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">Продуктовый концепт сервиса для постановки, распределения и отслеживания задач, с акцентом на прозрачность процессов и удобство командной работы.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="3" data-mp-cells-sm="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="../task-tracker/" aria-label="Таск-трекер для бек-офис системы — открыть кейс">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Таск-трекер для бек-офис системы</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Интерфейс для планирования и распределения задач в операционном отделе: общий пул, личный ToDo, обзор по исполнителям; вкладки, фильтры, доска / список / таблица, Drag&amp;Drop.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="../obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="../../../assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="../../../assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
<div id="nav-more-menu-floating" class="nav-more__floating" hidden data-nav-more-menu-floating aria-hidden="true"></div>
<script>
document.addEventListener('DOMContentLoaded',function(){
var s=document.body.dataset.currentCase;
if(!s)return;
document.querySelectorAll('[data-related-slug="'+s+'"]').forEach(function(el){el.hidden=true;});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+365
View File
@@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="How cookies, localStorage, and related browser technologies are used on Nikolai Mamadaev&#x27;s portfolio website."
/>
<title>Cookie Policy — Nikolai Mamadaev</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var h = typeof t.h === "number" ? t.h : typeof t.hue === "number" ? t.hue : null;
var s = typeof t.s === "number" ? t.s : typeof t.sat === "number" ? t.sat : null;
var l = typeof t.l === "number" ? t.l : typeof t.light === "number" ? t.light : null;
if (h !== null) d.style.setProperty("--theme-h", String(h));
if (s !== null) d.style.setProperty("--theme-s", String(s));
if (l !== null) {
var L = Number(l);
if (Number.isFinite(L)) d.style.setProperty("--theme-l", String(Math.min(70, Math.max(20, L))));
}
var hVal = h !== null ? h : 210;
var sVal = s !== null ? s : 90;
var lVal = l !== null ? Math.min(70, Math.max(20, Number(l))) : 40;
var H = (((hVal % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, sVal)) / 100;
var Lv = Math.min(100, Math.max(0, lVal)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "merriweather";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
.cookie-policy-doc {
max-width: 48rem;
margin: 0 auto;
}
.cookie-policy-doc h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.cookie-policy-doc .section__prose + .section__prose {
margin-top: 0.75rem;
}
.cookie-policy-doc table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
font-size: 0.92rem;
color: var(--text-muted);
}
.cookie-policy-doc th,
.cookie-policy-doc td {
border: 1px solid var(--border);
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.cookie-policy-doc th {
color: var(--text);
font-weight: 600;
background: color-mix(in srgb, var(--surface-elevated) 80%, transparent);
}
.cookie-policy-doc code {
font-family: var(--font);
font-size: 0.88em;
color: var(--text);
}
.cookie-policy-doc .section__prose a {
color: var(--accent);
}
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../../" aria-label="Home">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Page sections">
<a href="../../">Home</a>
</nav>
<nav class="lang-switch" aria-label="Choose language" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="../../#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="main" class="section section--tight">
<div class="section__inner cookie-policy-doc">
<p class="hero__eyebrow">Legal Information</p>
<h1 class="case-page__h1">Cookie and Local Storage Policy</h1>
<p class="section__prose"><strong>Last updated:</strong> May 9, 2026</p>
<p class="section__prose">
This document describes how browser-based data storage technologies are used on Nikolai Mamadaev’s personal portfolio website (hereinafter referred to as the “website”).
</p>
<h2 id="operator">Who Is Responsible for the Website</h2>
<p class="section__prose">
<strong>Nikolai Mamadaev</strong>
is the operator of the Site. For questions regarding data processing, please use the contact information in the “Contacts” section on
<a href="../../#contact">the home page</a>
(Telegram, VKontakte, Yandex, and email by copying the address using the button).
</p>
<h2 id="http-cookie">Are HTTP cookies used?</h2>
<p class="section__prose">
In the current version of the website, <strong>third-party advertising or analytics HTTP cookies are not enabled</strong>. The page markup does not contain analytics trackers (such as Yandex.Metrica or Google Analytics), which typically set their own cookies.
</p>
<p class="section__prose">
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.
</p>
<h2 id="local-storage">Local Storage and Similar Browser Technologies</h2>
<p class="section__prose">
To improve your experience, the website stores small amounts of data on your device using the browser’s <strong>localStorage mechanism</strong> (this is not an HTTP cookie, but it functions similarly and is also considered client-side storage).
</p>
<p class="section__prose">The following keys are used:</p>
<table>
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>siteThemeV1</code></td>
<td>
Appearance settings: theme (light/dark/system), tint, rounded corners, font, background, and related theme panel settings.
</td>
</tr>
<tr>
<td><code>siteCookieConsentV1</code></td>
<td>Value <code>accepted</code> if you clicked “Accept” in the cookie notification; after that, the banner is hidden.</td>
</tr>
</tbody>
</table>
<p class="section__prose">
This data <strong>is not</strong> automatically <strong>sent to the website’s server</strong>: it remains only in your browser.
</p>
<h2 id="retention">Retention Period</h2>
<p class="section__prose">
Data in localStorage is stored until you <strong>manually delete it</strong> (clear site data / completely clear browser data) or until the browser resets the storage according to its own rules.
</p>
<h2 id="consent">Consent and Opt-Out</h2>
<p class="section__prose">
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.
</p>
<p class="section__prose">
You can <strong>clear localStorage</strong> 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.
</p>
<h2 id="changes">Changes</h2>
<p class="section__prose">
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 <a href="../../cookie-policy/">/cookie-policy.html</a> relative to the site root.
</p>
</div>
</main>
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Website design and development — Nikolay Mamadaev</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../../cookie-policy/">Cookie policy</a></span>
</div>
</footer>
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="../../cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="../../assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
</body>
</html>
+1375
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -15
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "assets/fonts/merriweather.css",
geologica: "assets/fonts/geologica.css",
mono: "assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="assets/favicon/site.webmanifest" />
<link rel="preload" href="assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="css/main.css" as="style" />
<link rel="preload" href="js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="js/main.js" />
<link rel="stylesheet" href="css/main.css" />
<script src="js/skill-connectors.js" defer></script>
<script type="module" src="js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
@@ -127,6 +127,25 @@
</button>
<div id="nav-more-menu-dock" class="nav-more__dock" hidden data-nav-more-menu></div>
</div>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -1351,7 +1370,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="cookie-policy/">Политика cookie</a></span>
</div>
+35 -16
View File
@@ -11,9 +11,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "../assets/fonts/merriweather.css",
geologica: "../assets/fonts/geologica.css",
mono: "../assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
@@ -89,17 +89,17 @@
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="../assets/favicon/site.webmanifest" />
<link rel="preload" href="../assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="../css/main.css" as="style" />
<link rel="preload" href="../js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="../js/main.js" />
<link rel="stylesheet" href="../css/main.css" />
<script src="../js/skill-connectors.js" defer></script>
<script type="module" src="../js/main.js"></script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<style>
@@ -308,9 +308,28 @@
<div class="header__inner">
<a class="logo" href="../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<nav class="nav" aria-label="Разделы страницы">
<a href="../">На главную</a>
</nav>
<nav class="lang-switch" aria-label="Выбор языка" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">RU</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">EN</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
</div>
</div>
</header>
@@ -946,7 +965,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="../cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="../cookie-policy/">Политика cookie</a></span>
</div>
+95 -45
View File
@@ -4,10 +4,16 @@ import path from "node:path";
const rootDir = process.cwd();
const srcPagesDir = path.join(rootDir, "src", "pages");
const srcPartialsDir = path.join(rootDir, "src", "partials");
const srcI18nDir = path.join(rootDir, "src", "i18n");
const outDir = path.join(rootDir, "public");
// Локали: дефолтная (ru) собирается в корень public/, остальные — в public/<lang>/.
const LOCALES = ["ru", "en"];
const DEFAULT_LOCALE = "ru";
const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi;
const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
const i18nRe = /\{\{\s*i18n:([\w.-]+)\s*\}\}/g;
function readUtf8(filePath) {
return fs.readFileSync(filePath, "utf8");
@@ -37,20 +43,38 @@ function loadPartials() {
return partialMap;
}
function renderTemplate(input, partials, vars, stack = []) {
function loadI18n() {
const map = {};
for (const loc of LOCALES) {
const file = path.join(srcI18nDir, `${loc}.json`);
map[loc] = fs.existsSync(file) ? JSON.parse(readUtf8(file)) : {};
}
return map;
}
function resolveI18n(key, dict, fallback) {
if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return String(dict[key]);
if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return String(fallback[key]);
return `{{i18n:${key}}}`;
}
function renderTemplate(input, partials, vars, dict, fallback, loc, stack = []) {
const withIncludes = input.replace(includeRe, (_, name) => {
const key = String(name).trim();
if (!partials.has(key)) {
const partialKey = loc && loc !== DEFAULT_LOCALE && partials.has(`${key}.${loc}`) ? `${key}.${loc}` : key;
if (!partials.has(partialKey)) {
throw new Error(`Partial "${key}" not found`);
}
if (stack.includes(key)) {
throw new Error(`Circular partial include: ${[...stack, key].join(" -> ")}`);
if (stack.includes(partialKey)) {
throw new Error(`Circular partial include: ${[...stack, partialKey].join(" -> ")}`);
}
const partialContent = partials.get(key);
return renderTemplate(partialContent, partials, vars, [...stack, key]);
const partialContent = partials.get(partialKey);
return renderTemplate(partialContent, partials, vars, dict, fallback, loc, [...stack, partialKey]);
});
return withIncludes.replace(varRe, (_, name) => {
const withI18n = withIncludes.replace(i18nRe, (_, key) => resolveI18n(String(key).trim(), dict, fallback));
return withI18n.replace(varRe, (_, name) => {
const key = String(name).trim();
if (Object.prototype.hasOwnProperty.call(vars, key)) {
return String(vars[key]);
@@ -93,9 +117,29 @@ function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) {
}
}
/** Базовые переменны для страницы в дефолтной локали (как было до i18n). */
function baseVars(relNorm, depth) {
if (relNorm.startsWith("cases/")) {
return { assetPrefix: "../../", casesPrefix: "../" };
}
if (relNorm === "resume.html") {
return { assetPrefix: "../", casesPrefix: "" };
}
if (relNorm === "cookie-policy.html") {
return { assetPrefix: "../", casesPrefix: "" };
}
return {
assetPrefix: depth === 0 ? "" : "../".repeat(depth),
casesPrefix: depth === 0 ? "cases/" : "",
};
}
function build() {
const partials = loadPartials();
const pageFiles = walkHtmlFiles(srcPagesDir);
const i18n = loadI18n();
const defaultDict = i18n[DEFAULT_LOCALE] || {};
const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i");
const pageFiles = walkHtmlFiles(srcPagesDir).filter((p) => !localeSuffixRe.test(p));
for (const pagePath of pageFiles) {
const rel = path.relative(srcPagesDir, pagePath);
@@ -105,48 +149,54 @@ function build() {
const isCase = relNorm.startsWith("cases/");
const isResume = relNorm === "resume.html";
const isCookiePolicy = relNorm === "cookie-policy.html";
const omitFile = isCase ? path.basename(pagePath) : null;
let outPath;
let vars;
for (const loc of LOCALES) {
const dict = i18n[loc] || {};
const isDefault = loc === DEFAULT_LOCALE;
const langPrefix = isDefault ? "" : `${loc}/`;
if (isCase) {
const slug = path.basename(pagePath, ".html");
outPath = path.join(outDir, "cases", slug, "index.html");
vars = {
assetPrefix: "../../",
casesPrefix: "../",
};
} else if (isResume) {
outPath = path.join(outDir, "resume", "index.html");
vars = {
assetPrefix: "../",
casesPrefix: "",
};
} else if (isCookiePolicy) {
outPath = path.join(outDir, "cookie-policy", "index.html");
vars = {
assetPrefix: "../",
casesPrefix: "",
};
} else {
outPath = path.join(outDir, rel);
vars = {
assetPrefix: depth === 0 ? "" : "../".repeat(depth),
casesPrefix: depth === 0 ? "cases/" : "",
};
// Переведённый исходник страницы для локали, если есть.
let sourcePath = pagePath;
if (!isDefault) {
const override = pagePath.replace(/\.html$/, `.${loc}.html`);
if (fs.existsSync(override)) sourcePath = override;
}
const source = readUtf8(sourcePath);
const vars = baseVars(relNorm, depth);
// Для не-дефолтной локали всё содержимое уходит на уровень глубже (public/<lang>/),
// поэтому к assetPrefix добавляем один ../, а пустой casesPrefix — в ../cases/.
if (!isDefault) {
vars.assetPrefix = vars.assetPrefix === "" ? "../" : vars.assetPrefix + "../";
if (vars.casesPrefix === "") vars.casesPrefix = "../cases/";
}
vars.lang = loc;
let outPath;
if (isCase) {
const slug = path.basename(pagePath, ".html");
outPath = path.join(outDir, langPrefix, "cases", slug, "index.html");
} else if (isResume) {
outPath = path.join(outDir, langPrefix, "resume", "index.html");
} else if (isCookiePolicy) {
outPath = path.join(outDir, langPrefix, "cookie-policy", "index.html");
} else {
outPath = path.join(outDir, langPrefix, rel);
}
let rendered = renderTemplate(source, partials, vars, dict, defaultDict, loc);
// Подменяем язык в <html lang="..."> под текущую локаль.
rendered = rendered.replace(/<html\s+lang="ru"/, `<html lang="${loc}"`);
if (isCase) {
rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile);
}
ensureDir(path.dirname(outPath));
fs.writeFileSync(outPath, rendered, "utf8");
}
const source = readUtf8(pagePath);
let rendered = renderTemplate(source, partials, vars);
if (isCase) {
const omitFile = path.basename(pagePath);
rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile);
}
ensureDir(path.dirname(outPath));
fs.writeFileSync(outPath, rendered, "utf8");
}
console.log(`Built ${pageFiles.length} pages into public/`);
console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`);
}
build();
+19
View File
@@ -0,0 +1,19 @@
{
"nav.aria": "Page sections",
"nav.projects": "Portfolio",
"nav.experience": "Experience",
"nav.skills": "Skills",
"nav.miniProjects": "Activity",
"nav.contact": "Contact",
"skip.home": "Skip to main content",
"skip.case": "Skip to case content",
"logo.aria": "Home",
"more.sections": "More sections",
"back.home": "Home",
"footer.credit": "Website design and development — Nikolay Mamadaev",
"footer.designSystem": "Design System",
"footer.cookiePolicy": "Cookie policy",
"lang.aria": "Choose language",
"lang.ru": "RU",
"lang.en": "EN"
}
+19
View File
@@ -0,0 +1,19 @@
{
"nav.aria": "Разделы страницы",
"nav.projects": "Портфолио",
"nav.experience": "Опыт работы",
"nav.skills": "Навыки",
"nav.miniProjects": "Активность",
"nav.contact": "Контакты",
"skip.home": "К основному содержимому",
"skip.case": "К содержимому кейса",
"logo.aria": "На главную",
"more.sections": "Ещё разделы",
"back.home": "На главную",
"footer.credit": "Дизайн и разработка сайта — Николай Мамадаев",
"footer.designSystem": "Design System",
"footer.cookiePolicy": "Политика cookie",
"lang.aria": "Выбор языка",
"lang.ru": "RU",
"lang.en": "EN"
}
+414 -22
View File
@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Кейс: Card Columns — плагин для колоночного отображения карточек в Obsidian."
content="Кейс: Card Columns, плагин-вид для Obsidian Bases. Показывает заметки как карточки, разложенные по колонкам, с фильтрацией тегов по И/ИЛИ."
/>
<title>Card Columns — Плагин для Obsidian — кейс</title>
<title>Card Columns, плагин для Obsidian Bases: кейс</title>
{{> head-links}}
{{> head-plyr}}
<style>
@@ -30,9 +30,100 @@
margin-bottom: 0.75rem;
color: var(--text);
}
.case-article img,
.case-article video {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0 1rem;
}
.case-article ul,
.case-article ol {
margin: 0.5rem 0 1rem 1.25rem;
padding: 0;
}
.case-article li {
margin-bottom: 0.35rem;
}
.case-article a:not(.btn) {
color: var(--accent);
}
.case-article .case-brief {
list-style: none;
margin: 1.5rem 0 0;
padding: 1.1rem 1.25rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.85rem 1.5rem;
font-size: 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-md, 12px);
}
.case-article .case-brief li {
margin: 0;
}
.case-article .case-brief span {
display: block;
margin-bottom: 0.15rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.case-article .case-todo {
margin: 1rem 0 1.25rem;
padding: 0.9rem 1.1rem;
border-left: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 var(--radius-sm, 8px) var(--radius-sm, 8px) 0;
font-size: 0.92rem;
}
.case-article .case-todo p {
margin: 0 0 0.5rem;
}
.case-article .case-todo p:last-child {
margin-bottom: 0;
}
.case-article .case-todo__q {
font-weight: 600;
color: var(--accent);
}
.case-todo__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.85;
}
.case-article details.case-details {
margin: 0.75rem 0 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
border-radius: var(--radius-sm, 8px);
}
.case-article details.case-details > summary {
cursor: pointer;
font-weight: 600;
color: var(--accent);
padding: 0.25rem 0;
}
.case-article details.case-details[open] > summary {
margin-bottom: 0.5rem;
}
.case-block {
margin-top: 3rem;
padding-top: 2.5rem;
border-top: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.case-block:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
</style>
<script>
(function () {
@@ -86,33 +177,334 @@
{{> fab-stack}}
<main id="case-main" class="section section--tight">
<div class="section__inner case-article">
<p class="hero__eyebrow">Obsidian · Plugin</p>
<h1 class="case-page__h1">Card Columns</h1>
<p class="case-page__sub">Плагин для колоночного отображения карточек в Obsidian</p>
<p class="section__prose">
<!-- Здесь будет введение -->
<p class="hero__eyebrow">Obsidian Bases · Plugin</p>
<h1 class="case-page__h1">
Канбан-вид для Obsidian Bases,<br />который не&nbsp;трогает ваши заметки
</h1>
<p class="case-page__sub">
Card Columns: заметки как карточки по колонкам,<br />
отбор по тегам И/ИЛИ за два клика
</p>
<h2 id="context">Контекст</h2>
<!-- МЕДИА: hero-спойлер, финальный экран плагина. Раскомментировать при вставке скрина.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/hero.webp" alt="Card Columns: колонки карточек поверх .base файла" width="1920" height="1080" loading="eager" decoding="async" data-image-viewer />
<figcaption>Вид Columns поверх базы заметок</figcaption>
</figure>
-->
<ul class="case-brief">
<li><span>Роль</span>Продуктовый дизайн и разработка</li>
<li><span>Платформа</span>Obsidian Bases</li>
<li><span>Статус</span>Рабочий плагин, open source (MIT)</li>
<li><span>Команда</span>Соло, пет-проект</li>
<li><span>Срок</span>Черновой ответ, заменить</li>
</ul>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных?
</p>
<p>
Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу
собственной работы. Заменить на реальное.
</p>
</div>
<p class="section__prose">
<!-- Контекст проекта -->
В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а
в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он
плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их
раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns.
</p>
<h2 id="task">Задача</h2>
<p class="section__prose">
<!-- Описание задачи -->
</p>
<div class="case-block" id="kontekst">
<h2>Контекст</h2>
<p class="section__prose">
Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь
заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть
встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет
нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель
Bases наравне со штатными.
</p>
<p class="section__prose">
Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что
выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика
выборки остаётся на Bases, а моя задача, только представление и навигация по нему.
</p>
<!-- МЕДИА: общий скрин/видео обзора — доска колонок поверх .base файла.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/overview.webp" alt="Вид Card Columns поверх .base файла: заметки по колонкам" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Обзор вида</figcaption>
</figure>
-->
</div>
<h2 id="solution">Решение</h2>
<p class="section__prose">
<!-- Что было сделано -->
</p>
<div class="case-block" id="pochemu-svoi">
<h2>Почему не готовое</h2>
<p class="section__prose">
Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо:
</p>
<p class="section__prose">
<strong>Штатный вид карточек Bases.</strong> Рисует ровный поток карточек без группировки.
Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки.
Отпал первым.
</p>
<p class="section__prose">
<strong>Канбан-плагины из комьюнити.</strong> Большинство тянет за собой свою систему
хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база,
которую надо синхронизировать. Мне это было не нужно.
</p>
<p class="section__prose">
<strong>Соседний bases-kanban.</strong> Ближайший по духу: цепляется к Bases, делает
канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина
это риск, о котором ниже.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
Отказ от drag-and-drop, это не недоделка, а позиция. Плагин представления не должен
писать в пользовательские данные. Это решение определило всё остальное.
</p>
</div>
</aside>
<h2 id="result">Результат</h2>
<p class="section__prose">
<!-- Итоги -->
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие
именно смотрел?
</p>
<p>
Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban
смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать.
</p>
</div>
</div>
<div class="case-block" id="zadacha">
<h2>Задача</h2>
<p class="section__prose">
Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет
быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит
своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны
работать как раньше.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
<div class="case-callout__content">
<p>
Главный риск, сделать вид, который конфликтует со штатным тулбаром Bases. Тогда
пользователь теряет сортировку и поиск, ради которых Bases и берут. Этого нельзя
было допустить.
</p>
</div>
</aside>
</div>
<div class="case-block" id="reshenie">
<h2>Решения</h2>
<p class="section__prose">
Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса,
группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов,
которые определили результат. В каждом есть то, от чего я отказался.
</p>
<!-- МЕДИА: скриншот вида с колонками и карточками внутри.
<figure class="case-media case-media--wide">
<img src="../assets/card-columns-obsidian/board.webp" alt="Колонки карточек с группировкой по свойству" width="1920" height="1080" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Вид с колонками</figcaption>
</figure>
-->
<h3>Группировка по любому свойству</h3>
<p class="section__prose">
Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому
frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна
и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по
приоритету.
</p>
<h3>Фильтр тегов по И и ИЛИ</h3>
<p class="section__prose">
Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они
отсеивают карточки по всем колонкам сразу:
</p>
<ul class="section__prose">
<li>Левый клик по тегу, показать только его (сбрасывает остальные фильтры).</li>
<li>Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).</li>
<li>Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.</li>
<li>Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.</li>
<li>All, снять все фильтры.</li>
</ul>
<p class="section__prose">
Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной:
быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку,
отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано.
</p>
<p class="section__prose">
Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо
отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы
с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил.
Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда.
</p>
<!-- МЕДИА: короткое видео фильтрации по тегам (OR / AND).
<figure class="case-media" style="margin: 0.85rem 0 1.2rem">
<div class="case-video-shell">
<video controls preload="metadata" playsinline poster="../assets/card-columns-obsidian/filter-poster.webp" data-case-video-player style="display: block; width: 100%; height: auto; margin: 0">
<source src="../assets/card-columns-obsidian/filter.webm" type="video/webm" />
<source src="../assets/card-columns-obsidian/filter.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
</div>
<figcaption>Фильтрация по тегам: OR и AND</figcaption>
</figure>
-->
<h3>Карточки и обложки</h3>
<p class="section__prose">
По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно
показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки
полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание
(cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего).
</p>
<h3>Решение: настройки вынесены из кода</h3>
<p class="section__prose">
Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать
для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид
подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров.
</p>
<details class="case-details">
<summary>Показать полный список настроек</summary>
<p class="section__prose"><strong>General</strong></p>
<ul class="section__prose">
<li>Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.</li>
<li>Ширина карточки (px).</li>
<li>Колонок в группе (1–6), сетка внутри колонки.</li>
<li>Зебра колонок (чередование фона для сканирования).</li>
<li>Masonry-раскладка (карточки заполняют вертикальные пустоты).</li>
</ul>
<p class="section__prose"><strong>Title</strong></p>
<ul class="section__prose">
<li>Перенос заголовка, жирный заголовок, размер шрифта (px).</li>
</ul>
<p class="section__prose"><strong>Properties</strong></p>
<ul class="section__prose">
<li>Раскладка чипов: стек (вертикально) или сетка (компактно в строку).</li>
<li>Перенос многострочных значений, размер шрифта.</li>
<li>Формат даты и даты-времени + локаль (Moment.js).</li>
</ul>
<p class="section__prose"><strong>Cover</strong></p>
<ul class="section__prose">
<li>Источник: нет / первая картинка в заметке / свойство-обложка.</li>
<li>Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.</li>
</ul>
</details>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса
в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно.
</p>
<p>
Черновой ответ: да, первая версия была под мою базу, настройки появились, когда
понадобилось открыть плагин наружу. Проверить и поправить.
</p>
</div>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">💡</span>
<div class="case-callout__content">
<p>
Сплит-превью сделал как в самом Obsidian: левый клик переиспользует панель, Ctrl+Клик,
новую вкладку внутри сплита. Без этого на широком экране карточки неудобно
просматривать рядом с доской.
</p>
</div>
</aside>
<h3>Работает внутри штатного тулбара</h3>
<p class="section__prose">
Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из
тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не
дублируются и не зависят от отдельного хранилища.
</p>
<!-- МЕДИА: скриншот настроек (gear-меню) с секциями.
<figure class="case-media case-media--content">
<img src="../assets/card-columns-obsidian/settings.webp" alt="Окно настроек плагина: General, Title, Properties, Cover" width="1200" height="675" loading="lazy" decoding="async" data-image-viewer />
<figcaption>Настройки вида</figcaption>
</figure>
-->
</div>
<div class="case-block" id="rezultat">
<h2>Результат и как я это мерял</h2>
<p class="section__prose">
Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента
карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному
виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не
ломают.
</p>
<h3>Замер: сколько действий на отбор</h3>
<p class="section__prose">
Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную
карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В
Columns, два клика по пилюлям и колонка перед глазами.
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный
вид против Columns. Или посчитать точное число действий.
</p>
<p>
Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и
требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные,
заменить на замеренные.
</p>
</div>
<h3>Замер: живучесть в ежедневной работе</h3>
<p class="section__prose">
Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я
пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует
вспоминать, как им управлять.
</p>
<div class="case-todo">
<span class="case-todo__label">Вопрос к автору</span>
<p class="case-todo__q">
Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая
цифра, подтверждающая, что плагином пользуются не только ты.
</p>
<p>
Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так
что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда,
это сильнее любого-self-report.
</p>
</div>
<p class="section__prose">
Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно,
это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных.
Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А
пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что
мешает им пользоваться сходу.
</p>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">✅</span>
<div class="case-callout__content">
<p>
Главный вывод: вид для Bases получается рабочим только тогда, когда он уважает
тулбар фреймворка. Я добавил представление, а не вторую систему хранения, и заметки
остались обычными заметками.
</p>
</div>
</aside>
</div>
</div>
</main>
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="How cookies, localStorage, and related browser technologies are used on Nikolai Mamadaev&#x27;s portfolio website."
/>
<title>Cookie Policy — Nikolai Mamadaev</title>
{{> head-links}}
<style>
.cookie-policy-doc {
max-width: 48rem;
margin: 0 auto;
}
.cookie-policy-doc h2 {
margin: 2rem 0 0.75rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.cookie-policy-doc .section__prose + .section__prose {
margin-top: 0.75rem;
}
.cookie-policy-doc table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
font-size: 0.92rem;
color: var(--text-muted);
}
.cookie-policy-doc th,
.cookie-policy-doc td {
border: 1px solid var(--border);
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.cookie-policy-doc th {
color: var(--text);
font-weight: 600;
background: color-mix(in srgb, var(--surface-elevated) 80%, transparent);
}
.cookie-policy-doc code {
font-family: var(--font);
font-size: 0.88em;
color: var(--text);
}
.cookie-policy-doc .section__prose a {
color: var(--accent);
}
</style>
</head>
<body>
{{> header-minimal}}
{{> fab-stack-home-contact}}
<main id="main" class="section section--tight">
<div class="section__inner cookie-policy-doc">
<p class="hero__eyebrow">Legal Information</p>
<h1 class="case-page__h1">Cookie and Local Storage Policy</h1>
<p class="section__prose"><strong>Last updated:</strong> May 9, 2026</p>
<p class="section__prose">
This document describes how browser-based data storage technologies are used on Nikolai Mamadaev’s personal portfolio website (hereinafter referred to as the “website”).
</p>
<h2 id="operator">Who Is Responsible for the Website</h2>
<p class="section__prose">
<strong>Nikolai Mamadaev</strong>
is the operator of the Site. For questions regarding data processing, please use the contact information in the “Contacts” section on
<a href="{{assetPrefix}}#contact">the home page</a>
(Telegram, VKontakte, Yandex, and email by copying the address using the button).
</p>
<h2 id="http-cookie">Are HTTP cookies used?</h2>
<p class="section__prose">
In the current version of the website, <strong>third-party advertising or analytics HTTP cookies are not enabled</strong>. The page markup does not contain analytics trackers (such as Yandex.Metrica or Google Analytics), which typically set their own cookies.
</p>
<p class="section__prose">
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.
</p>
<h2 id="local-storage">Local Storage and Similar Browser Technologies</h2>
<p class="section__prose">
To improve your experience, the website stores small amounts of data on your device using the browser’s <strong>localStorage mechanism</strong> (this is not an HTTP cookie, but it functions similarly and is also considered client-side storage).
</p>
<p class="section__prose">The following keys are used:</p>
<table>
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>siteThemeV1</code></td>
<td>
Appearance settings: theme (light/dark/system), tint, rounded corners, font, background, and related theme panel settings.
</td>
</tr>
<tr>
<td><code>siteCookieConsentV1</code></td>
<td>Value <code>accepted</code> if you clicked “Accept” in the cookie notification; after that, the banner is hidden.</td>
</tr>
</tbody>
</table>
<p class="section__prose">
This data <strong>is not</strong> automatically <strong>sent to the website’s server</strong>: it remains only in your browser.
</p>
<h2 id="retention">Retention Period</h2>
<p class="section__prose">
Data in localStorage is stored until you <strong>manually delete it</strong> (clear site data / completely clear browser data) or until the browser resets the storage according to its own rules.
</p>
<h2 id="consent">Consent and Opt-Out</h2>
<p class="section__prose">
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.
</p>
<p class="section__prose">
You can <strong>clear localStorage</strong> 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.
</p>
<h2 id="changes">Changes</h2>
<p class="section__prose">
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 <a href="{{assetPrefix}}cookie-policy/">/cookie-policy.html</a> relative to the site root.
</p>
</div>
</main>
{{> footer}}
{{> cookie-notice}}
</body>
</html>
+564
View File
@@ -0,0 +1,564 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Product designer and vibe coder with over 10 years of experience. I design digital products, interfaces and design systems, combining UX, no-code and code for clean, working solutions."
/>
<title>Nikolay Mamadaev — Product Designer</title>
{{> head-links}}
</head>
<body>
{{> header-home}}
{{> fab-stack}}
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<div class="hero__grid">
<div class="hero__text">
<p class="hero__eyebrow">Nikolay Mamadaev</p>
<h1 id="hero-title" class="hero__title">
Taming complex interfaces
<span class="hero-rotator" data-hero-rotator aria-label="web, mobile, desktop">
<span class="hero-rotator__word" data-hero-rotator-word>web</span>
</span>
</h1>
<div class="hero__actions">
<a class="btn btn--primary btn--flat" href="#contact"><span class="btn__icon" aria-hidden="true"></span><span>Get in touch</span></a>
<a class="btn btn--secondary btn--flat" href="#projects"><span class="btn__icon" aria-hidden="true"></span><span>View portfolio</span></a>
<a class="btn btn--secondary btn--flat btn--resume" href="resume/"><span class="btn__icon" aria-hidden="true"></span><span>Resume</span></a>
</div>
</div>
<div class="hero__visual" aria-hidden="true">
<div class="hero__portrait-shell">
<div class="hero__portrait">
<div class="hero__portrait-inner">
<div class="hero__portrait-layers">
<div class="hero__portrait-layer hero__portrait-layer--bg">
<img
class="hero__photo-bg"
src="{{assetPrefix}}assets/index/me_static.webp"
alt=""
width="320"
height="320"
loading="eager"
decoding="async"
/>
</div>
<div class="hero__portrait-layer hero__portrait-layer--motion" data-hero-motion-layer>
<video
class="hero__photo-motion"
data-hero-motion-video
data-src="{{assetPrefix}}assets/index/me_motion.webm"
poster="{{assetPrefix}}assets/index/me_motion.webp"
muted
loop
playsinline
preload="none"
aria-hidden="true"
></video>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="marquee" aria-hidden="true" data-reveal>
<div class="marquee__track">
<div class="marquee__content">
<span class="hero__tag">UX/UI</span>
<span class="hero__tag">10+ years of experience</span>
<span class="hero__tag">Product thinking</span>
<span class="hero__tag">Design systems</span>
<span class="hero__tag">Mentoring &amp; coaching</span>
<span class="hero__tag">AI tools &amp; vibe coding</span>
</div>
<div class="marquee__content" aria-hidden="true">
<span class="hero__tag">UX/UI</span>
<span class="hero__tag">10+ years of experience</span>
<span class="hero__tag">Product thinking</span>
<span class="hero__tag">Design systems</span>
<span class="hero__tag">Mentoring &amp; coaching</span>
<span class="hero__tag">AI tools &amp; vibe coding</span>
</div>
<div class="marquee__content" aria-hidden="true">
<span class="hero__tag">UX/UI</span>
<span class="hero__tag">10+ years of experience</span>
<span class="hero__tag">Product thinking</span>
<span class="hero__tag">Design systems</span>
<span class="hero__tag">Mentoring &amp; coaching</span>
<span class="hero__tag">AI tools &amp; vibe coding</span>
</div>
</div>
</section>
{{> projects-section-mp}}{{> projects-section}}
<section id="experience" class="section section--surface" aria-labelledby="experience-title" data-reveal>
<div class="section__inner">
<h2 id="experience-title" class="section__title">Experience</h2>
<ol id="experience-list" class="experience-grid">
<li class="experience-grid__item">
<div class="exp-card" data-exp-order="1">
<div class="exp-card__time-row">
<span class="exp-card__period">January 2026 — Present</span>
</div>
<h3 class="exp-card__company">
Astratech
</h3>
<p class="exp-card__desc">
Development and support of digital systems and tools that boost team efficiency.
</p>
</div>
</li>
<li class="experience-grid__item">
<a
class="exp-card exp-card--link"
href="https://primo-rpa.com/"
target="_blank"
rel="noopener noreferrer"
data-exp-order="2"
aria-label="Primo RPA — open company website"
>
<div class="exp-card__time-row">
<span class="exp-card__period">February 2023 — November 2025</span>
<span class="exp-card__sep" aria-hidden="true">·</span>
<span class="exp-card__duration">2 years 10 months</span>
</div>
<h3 class="exp-card__company">
Primo RPA
<span class="exp-card__ext" aria-hidden="true"></span>
</h3>
<p class="exp-card__desc">
A business process automation platform with a suite of products: a studio for building bots, an orchestrator to manage them, an Idea Hub for process selection, and AI servers for models and data recognition.
</p>
</a>
</li>
<li class="experience-grid__item">
<a
class="exp-card exp-card--link"
href="https://icons8.com/lunacy"
target="_blank"
rel="noopener noreferrer"
data-exp-order="3"
aria-label="Icons8 (Lunacy) — open company website"
>
<div class="exp-card__time-row">
<span class="exp-card__period">July 2022 — January 2023</span>
<span class="exp-card__sep" aria-hidden="true">·</span>
<span class="exp-card__duration">7 months</span>
</div>
<h3 class="exp-card__company">
Icons8 (Lunacy)
<span class="exp-card__ext" aria-hidden="true"></span>
</h3>
<p class="exp-card__desc">
A vector editor for UI/UX and web design with Sketch support, a built-in library of icons/photos/illustrations, and a set of AI tools to speed up routine work.
</p>
</a>
</li>
<li class="experience-grid__item">
<a
class="exp-card exp-card--link"
href="https://manu.co/"
target="_blank"
rel="noopener noreferrer"
data-exp-order="4"
aria-label="MANU Video Game Maker — open company website"
>
<div class="exp-card__time-row">
<span class="exp-card__period">January 2019 — December 2021</span>
<span class="exp-card__sep" aria-hidden="true">·</span>
<span class="exp-card__duration">3 years</span>
</div>
<h3 class="exp-card__company">
MANU Video Game Maker
<span class="exp-card__ext" aria-hidden="true"></span>
</h3>
<p class="exp-card__desc">
No-code/low-code platform for building 2D and 3D games without writing code.
</p>
</a>
</li>
<li class="experience-grid__item" data-exp-extra hidden>
<a
class="exp-card exp-card--link"
href="https://www.orioninnovation.com/"
target="_blank"
rel="noopener noreferrer"
data-exp-order="6"
aria-label="Orion Innovation (ex MERA) — open company website"
>
<div class="exp-card__time-row">
<span class="exp-card__period">July 2016 — October 2017</span>
<span class="exp-card__sep" aria-hidden="true">·</span>
<span class="exp-card__duration">1 year 4 months</span>
</div>
<h3 class="exp-card__company">
Orion Innovation (ex MERA)
<span class="exp-card__ext" aria-hidden="true"></span>
</h3>
<p class="exp-card__desc">
An international technology company specializing in custom development and engineering solutions for large enterprise clients.
</p>
</a>
</li>
<li class="experience-grid__item" data-exp-extra hidden>
<a
class="exp-card exp-card--link"
href="https://aegees.com/"
target="_blank"
rel="noopener noreferrer"
data-exp-order="5"
aria-label="Aegees — open company website"
>
<div class="exp-card__time-row">
<span class="exp-card__period">October 2017 — December 2018</span>
<span class="exp-card__sep" aria-hidden="true">·</span>
<span class="exp-card__duration">1 year 3 months</span>
</div>
<h3 class="exp-card__company">
Aegees
<span class="exp-card__ext" aria-hidden="true"></span>
</h3>
<p class="exp-card__desc">
A secure messenger with end-to-end encryption and no message storage on servers, focused on maximum user privacy.
</p>
</a>
</li>
<li class="experience-grid__item" data-exp-extra hidden>
<a
class="exp-card exp-card--link"
href="https://spaceshipapps.ru/"
target="_blank"
rel="noopener noreferrer"
data-exp-order="7"
aria-label="SpaceshipApps — open company website"
>
<div class="exp-card__time-row">
<span class="exp-card__period">June 2015 — June 2016</span>
<span class="exp-card__sep" aria-hidden="true">·</span>
<span class="exp-card__duration">1 year 1 month</span>
</div>
<h3 class="exp-card__company">
SpaceshipApps
<span class="exp-card__ext" aria-hidden="true"></span>
</h3>
<p class="exp-card__desc">
A studio building mobile and web apps end-to-end for businesses and entrepreneurs.
</p>
</a>
</li>
</ol>
<div class="experience__actions">
<button
type="button"
class="btn btn--secondary btn--flat experience__toggle"
data-exp-toggle
aria-expanded="false"
aria-controls="experience-list"
>
Show all
</button>
</div>
</div>
</section>
<section id="skills" class="section section--tight" aria-labelledby="skills-title" data-reveal>
<div class="section__inner">
<h2 id="skills-title" class="section__title">Skills</h2>
<div
class="skills-intro skills-intro--chat section__prose"
data-skills-intro
role="region"
aria-labelledby="skills-title"
>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__author">Nikolay M:</p>
<p class="skills-msg__text">
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.
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__text">
Below are examples of real business tasks and how I approached them
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
</div>
<div class="skills-categories">
<section class="skills-category" aria-labelledby="skills-cat-1-heading">
<div class="skills-category__layout">
<h3 id="skills-cat-1-heading" class="skills-category__title">
<button
class="skills-category__toggle"
type="button"
aria-expanded="false"
aria-controls="skills-cat-1"
data-skills-accordion-toggle
>
<span class="skills-category__title-text">Product thinking &amp; interface logic</span>
<span class="skills-category__chevron" aria-hidden="true"></span>
</button>
</h3>
<div id="skills-cat-1" class="skills-category__grid skills-category__panel" data-skills-accordion-panel hidden>
<article class="skill-task-card">
<p class="skill-task-card__task">
Make a complex product clearer so users reach key actions.
</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I map the user journey and cut steps that don't move them toward the goal.
</p>
<p class="skill-task-card__solution">
I design flows so every step makes clear what's happening and what to do next.
</p>
<p class="skill-task-card__solution">
I make even complex features feel natural by using familiar patterns and standard UI elements.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Improve the first steps in the product and guide users to their first "aha" moment.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I remove everything that distracts from the first result: extra steps, optional fields, secondary details.
</p>
<p class="skill-task-card__solution">
I replace long explanations with small hints exactly when they're needed — no lengthy tours or complex manuals.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Reduce support load through interface design.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I collect frequent requests and, based on them, simplify the flow and UI so users can solve issues on their own.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Tie the interface to product goals, not just a feature list.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I start from the business and user problem we're solving, then build flows and UI around it.
</p>
<p class="skill-task-card__solution">
I turn scenario problems into clear hypotheses and help the PM pick what actually moves the needle.
</p>
</div>
</article>
</div>
</div>
</section>
<section class="skills-category" aria-labelledby="skills-cat-2-heading">
<div class="skills-category__layout">
<h3 id="skills-cat-2-heading" class="skills-category__title">
<button
class="skills-category__toggle"
type="button"
aria-expanded="false"
aria-controls="skills-cat-2"
data-skills-accordion-toggle
>
<span class="skills-category__title-text">Design systems &amp; consistency</span>
<span class="skills-category__chevron" aria-hidden="true"></span>
</button>
</h3>
<div id="skills-cat-2" class="skills-category__grid skills-category__panel" data-skills-accordion-panel hidden>
<article class="skill-task-card">
<p class="skill-task-card__task">
Bring several products or modules to a unified look and logic.
</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I assemble a shared UI kit and core principles usable across all products.
</p>
<p class="skill-task-card__solution">
I surface repeated patterns and consolidate them into one system instead of a dozen similar solutions.
</p>
<p class="skill-task-card__solution">
I unify scattered solutions into a single design system that's comfortable for both designers and developers.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Make the interface scalable without piling up hacks.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I define simple design principles and component usage rules.
</p>
<p class="skill-task-card__solution">
I set up the design system structure so it's genuinely maintainable and extensible with new patterns.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Tidy up interfaces that grew piece by piece over time.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I merge similar solutions into common components and patterns, removing duplicates and accidental differences.
</p>
<p class="skill-task-card__solution">
I keep the interface clean and modern: readable typography, clear hierarchy, accents without visual noise.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Refresh the product's look so it doesn't feel dated next to competitors.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I start with frequently used screens and elements so the impact shows for both users and business.
</p>
<p class="skill-task-card__solution">
I align update stages with the current roadmap and dev workflow so familiar flows and releases aren't broken.
</p>
<p class="skill-task-card__solution">
I set the base style — typography, color, spacing — so the interface looks clean and modern.
</p>
</div>
</article>
</div>
</div>
</section>
<section class="skills-category" aria-labelledby="skills-cat-3-heading">
<div class="skills-category__layout">
<h3 id="skills-cat-3-heading" class="skills-category__title">
<button
class="skills-category__toggle"
type="button"
aria-expanded="false"
aria-controls="skills-cat-3"
data-skills-accordion-toggle
>
<span class="skills-category__title-text">Research, metrics &amp; analytics</span>
<span class="skills-category__chevron" aria-hidden="true"></span>
</button>
</h3>
<div id="skills-cat-3" class="skills-category__grid skills-category__panel" data-skills-accordion-panel hidden>
<article class="skill-task-card">
<p class="skill-task-card__task">
Get a real picture of user behavior instead of relying on the team's guesses.
</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I talk to users: I watch how they solve their tasks in the product and what they do at each step.
</p>
<p class="skill-task-card__solution">
I turn research findings into concrete changes in the interface and flows.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Make sure key flows work before shipping them to production.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I prepare simple usability-test scenarios based on real user tasks.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Understand how interface changes affect the product's key metrics.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I help describe the key events and funnels that reflect real user steps.
</p>
<p class="skill-task-card__solution">
I look at metrics alongside real usage scenarios and suggest interface changes that make things easier for users and better for the product.
</p>
</div>
</article>
</div>
</div>
</section>
<section class="skills-category" aria-labelledby="skills-cat-4-heading">
<div class="skills-category__layout">
<h3 id="skills-cat-4-heading" class="skills-category__title">
<button
class="skills-category__toggle"
type="button"
aria-expanded="false"
aria-controls="skills-cat-4"
data-skills-accordion-toggle
>
<span class="skills-category__title-text">Design team growth &amp; mentoring</span>
<span class="skills-category__chevron" aria-hidden="true"></span>
</button>
</h3>
<div id="skills-cat-4" class="skills-category__grid skills-category__panel" data-skills-accordion-panel hidden>
<article class="skill-task-card">
<p class="skill-task-card__task">Build clear processes inside the design team.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I introduce regular design reviews of shipped work, where everyone gets useful, constructive feedback.
</p>
<p class="skill-task-card__solution">
I agree on a task brief template and transparent acceptance criteria, so it's clear when a task is truly done.
</p>
<p class="skill-task-card__solution">
I set up a workflow that handles both planned work and urgent "on-fire" tasks.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">
Make design, product, and engineering work as one team, not three separate camps.
</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I act as the bridge: discuss flows with the PM, constraints with engineering, and capture decisions in the interface.
</p>
</div>
</article>
<article class="skill-task-card">
<p class="skill-task-card__task">Hire and grow product designers for a complex product.</p>
<div class="skill-task-card__solutions">
<p class="skill-task-card__solution">
I help shape the candidate requirements.
</p>
<p class="skill-task-card__solution">
I take part in hiring: I check how a candidate handles flows, constraints, and complex cases.
</p>
<p class="skill-task-card__solution">
I structure interviews to assess thinking and tooling.
</p>
<p class="skill-task-card__solution">
I set up onboarding through real tasks and regular reviews so the designer gets up to speed and adds value faster.
</p>
<p class="skill-task-card__solution">
I run regular 1:1s with designers: we review decisions, growth areas, and next steps.
</p>
</div>
</article>
</div>
</div>
</section>
</div>
</div>
</section>
{{> mini-projects-section}}
{{> contact-section}}
{{> footer}}
{{> cookie-notice}}
{{> nav-more-floating}}
</body>
</html>
+3 -3
View File
@@ -39,7 +39,7 @@
<div class="hero__portrait-layer hero__portrait-layer--bg">
<img
class="hero__photo-bg"
src="assets/index/me_static.webp"
src="{{assetPrefix}}assets/index/me_static.webp"
alt=""
width="320"
height="320"
@@ -51,8 +51,8 @@
<video
class="hero__photo-motion"
data-hero-motion-video
data-src="assets/index/me_motion.webm"
poster="assets/index/me_motion.webp"
data-src="{{assetPrefix}}assets/index/me_motion.webm"
poster="{{assetPrefix}}assets/index/me_motion.webp"
muted
loop
playsinline
+11
View File
@@ -0,0 +1,11 @@
<section id="contact" class="section section--surface section--footerish" data-reveal>
<div class="section__inner">
<h2 class="section__title">Contact</h2>
<ul class="contact-grid">
<li><a class="contact-card contact-card--link contact-card--brand-tg" href="https://t.me/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="{{assetPrefix}}assets/shared/icons/tg.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Telegram</span><span class="contact-card__hint">I'll reply today</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-vk" href="https://vk.com/mamadaevv" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="{{assetPrefix}}assets/shared/icons/vk.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">VK</span><span class="contact-card__hint">Stable connection</span></span></a></li>
<li><a class="contact-card contact-card--link contact-card--brand-ya" href="https://yandex.ru/chat/p/727e71fa-89b5-44a8-90a5-851021631a1c?utm_source=invite" rel="noopener noreferrer"><span class="contact-card__corner" aria-hidden="true"></span><span class="contact-card__icon" aria-hidden="true"><img src="{{assetPrefix}}assets/shared/icons/yandex_telemost.svg" alt="" width="24" height="24" loading="eager" decoding="async" /></span><span class="contact-card__body"><span class="contact-card__name">Yandex</span><span class="contact-card__hint">Yes, you can reach me here too</span></span></a></li>
<li><button type="button" class="contact-card contact-card--action contact-card--brand-email" data-contact-copy data-email-b64="bmlrQG1hbWFkYWV2di5zaXRl" data-hint-default="Copy address" aria-label="Copy email address to clipboard"><span class="contact-card__corner contact-card__corner--copy" aria-hidden="true"></span><span class="contact-card__icon contact-card__icon--accent" aria-hidden="true"></span><span class="contact-card__body"><span class="contact-card__name">Email</span><span class="contact-card__hint" data-contact-copy-hint>Copy address</span></span></button></li>
</ul>
</div>
</section>
+12
View File
@@ -0,0 +1,12 @@
<aside class="cookie-notice glass-neutral" hidden data-cookie-notice>
<p class="cookie-notice__text">
This site uses cookies for a comfortable experience. Read more in
<a href="{{assetPrefix}}cookie-policy/" data-cookie-policy-link>cookie policy</a>.
</p>
<button type="button" class="btn btn--secondary btn--flat cookie-notice__accept" data-cookie-accept>
<span>Accept</span>
<span class="cookie-notice__accept-icon" aria-hidden="true">
<img src="{{assetPrefix}}assets/shared/icons/сookie.svg" alt="" width="20" height="20" loading="eager" decoding="async" />
</span>
</button>
</aside>
+62
View File
@@ -0,0 +1,62 @@
<div class="fab-stack" aria-label="Quick actions">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Style settings">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Theme &amp; appearance" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Accent color</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Accent saturation and lightness"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Accent hue</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Theme</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="System"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Light"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Dark"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Corner radius</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Soft radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Medium radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Strong radius"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Typography</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Page background</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Show background gradient"><span class="theme-mesh-bg-seg__label">On</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Hide background gradient"><span class="theme-mesh-bg-seg__label">Off</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Contact">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
{{> content-edit-fab-stack}}
+3 -3
View File
@@ -1,7 +1,7 @@
<footer class="footer">
<div class="footer__inner">
<span class="footer__chunk footer__chunk--date">© 2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">Дизайн и разработка сайта — Николай Мамадаев</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="{{assetPrefix}}cases/design-system/">Design System</a><span class="footer__sep" aria-hidden="true"> · </span><a href="{{assetPrefix}}cookie-policy/">Политика cookie</a></span>
<span class="footer__chunk footer__chunk--date">© 2015–2026<span class="footer__sep" aria-hidden="true"> · </span></span>
<span class="footer__chunk footer__chunk--main">{{i18n:footer.credit}}</span>
<span class="footer__chunk footer__chunk--meta footer__attr"><span class="footer__sep" aria-hidden="true"> · </span><a href="{{assetPrefix}}cases/design-system/">{{i18n:footer.designSystem}}</a><span class="footer__sep" aria-hidden="true"> · </span><a href="{{assetPrefix}}cookie-policy/">{{i18n:footer.cookiePolicy}}</a></span>
</div>
</footer>
+9 -9
View File
@@ -1,11 +1,11 @@
{{> theme-init-script}}
<link rel="icon" type="image/png" sizes="32x32" href="{{assetPrefix}}assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="{{assetPrefix}}assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="{{assetPrefix}}assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="{{assetPrefix}}assets/favicon/site.webmanifest" />
<link rel="preload" href="{{assetPrefix}}assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="{{assetPrefix}}css/main.css" as="style" />
<link rel="preload" href="{{assetPrefix}}js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="{{assetPrefix}}js/main.js" />
<link rel="stylesheet" href="{{assetPrefix}}css/main.css" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="stylesheet" href="/css/main.css" />
{{> head-scripts}}
+1 -1
View File
@@ -1 +1 @@
<link rel="stylesheet" href="{{assetPrefix}}vendor/plyr/plyr.css" />
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
+2 -2
View File
@@ -1,2 +1,2 @@
<script src="{{assetPrefix}}js/skill-connectors.js" defer></script>
<script type="module" src="{{assetPrefix}}js/main.js"></script>
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
+5 -4
View File
@@ -1,11 +1,12 @@
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<a class="skip-link" href="#case-main">{{i18n:skip.case}}</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="{{assetPrefix}}" aria-label="На главную">НМ</a>
<a class="logo" href="{{assetPrefix}}" aria-label="{{i18n:logo.aria}}">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация по странице">
<a href="#contact">Контакты</a>
<nav class="nav" aria-label="{{i18n:nav.aria}}">
<a href="#contact">{{i18n:nav.contact}}</a>
</nav>
{{> lang-switch}}
</div>
</div>
</header>
+10 -9
View File
@@ -1,25 +1,26 @@
<div class="page-mesh-bg" aria-hidden="true"></div>
<a class="skip-link" href="#main">К основному содержимому</a>
<a class="skip-link" href="#main">{{i18n:skip.home}}</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="#" aria-label="На главную">НМ</a>
<a class="logo" href="#" aria-label="{{i18n:logo.aria}}">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы" data-top-nav>
<a href="#projects">Портфолио</a>
<a href="#experience">Опыт работы</a>
<a href="#skills">Навыки</a>
<a href="#mini-projects">Активность</a>
<a href="#contact">Контакты</a>
<nav class="nav" aria-label="{{i18n:nav.aria}}" data-top-nav>
<a href="#projects">{{i18n:nav.projects}}</a>
<a href="#experience">{{i18n:nav.experience}}</a>
<a href="#skills">{{i18n:nav.skills}}</a>
<a href="#mini-projects">{{i18n:nav.miniProjects}}</a>
<a href="#contact">{{i18n:nav.contact}}</a>
</nav>
<div class="nav-more" hidden data-nav-more>
<button type="button" class="nav-more__btn" data-nav-more-btn aria-expanded="false" aria-controls="nav-more-menu-dock nav-more-menu-floating" aria-label="Ещё разделы">
<button type="button" class="nav-more__btn" data-nav-more-btn aria-expanded="false" aria-controls="nav-more-menu-dock nav-more-menu-floating" aria-label="{{i18n:more.sections}}">
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div id="nav-more-menu-dock" class="nav-more__dock" hidden data-nav-more-menu></div>
</div>
{{> lang-switch}}
</div>
</div>
</header>
+5 -4
View File
@@ -1,12 +1,13 @@
<a class="skip-link" href="#main">К основному содержимому</a>
<a class="skip-link" href="#main">{{i18n:skip.home}}</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="{{assetPrefix}}" aria-label="На главную">НМ</a>
<a class="logo" href="{{assetPrefix}}" aria-label="{{i18n:logo.aria}}">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Навигация">
<a href="{{assetPrefix}}">На главную</a>
<nav class="nav" aria-label="{{i18n:nav.aria}}">
<a href="{{assetPrefix}}">{{i18n:back.home}}</a>
</nav>
{{> lang-switch}}
</div>
</div>
</header>
+18
View File
@@ -0,0 +1,18 @@
<nav class="lang-switch" aria-label="{{i18n:lang.aria}}" data-lang-switch>
<a class="lang-switch__btn" data-lang="ru" href="/">{{i18n:lang.ru}}</a>
<a class="lang-switch__btn" data-lang="en" href="/en/">{{i18n:lang.en}}</a>
</nav>
<script>
(function () {
var p = location.pathname.replace(/^\/+/, "");
var isEn = p.indexOf("en/") === 0;
var base = isEn ? p.slice(3) : p;
var links = document.querySelectorAll("[data-lang-switch] [data-lang]");
for (var i = 0; i < links.length; i++) {
var b = links[i];
var l = b.getAttribute("data-lang");
b.setAttribute("href", (l === "en" ? "/en/" : "/") + base);
if ((l === "en") === isEn) b.setAttribute("aria-current", "true");
}
})();
</script>
+158
View File
@@ -0,0 +1,158 @@
<section id="mini-projects" class="section section--surface" aria-labelledby="mini-projects-title" data-reveal>
<div class="section__inner">
<h2 id="mini-projects-title" class="section__title">Activity</h2>
<div
class="skills-intro skills-intro--chat section__prose"
data-skills-intro
role="region"
aria-labelledby="mini-projects-title"
>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__author">Nikolay M:</p>
<p class="skills-msg__text">
I enjoy building small side projects and sharing something useful with the world.
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
</div>
<ol id="mini-projects-list" class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://www.figma.com/@mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Figma Community — open profile"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/figma_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Figma Community
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
I publish useful files and plugins for the community.
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://dribbble.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Dribbble — open profile"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/dribbble_logo.webp"
alt=""
width="1024"
height="1024"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Dribbble
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Nice visuals with UI elements.
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://t.me/designnotpizza"
target="_blank"
rel="noopener noreferrer"
aria-label="Design is not pizza — open Telegram channel"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/tg-channel-logo-design_not_pizza.webp"
alt=""
width="640"
height="640"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Design is not pizza
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
My channel about design. Posts on product design and useful links.
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/ai-assistant-figma/"
aria-label="AI assistant — open case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">Figma plugin</li>
</ul>
<p class="mp-card__desc">A Figma plugin with AI features.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/card-columns-obsidian/"
aria-label="Card Columns — open case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">Obsidian plugin</li>
</ul>
<p class="mp-card__desc">A plugin for column-based card view in Obsidian.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/site-with-ai-ide/"
aria-label="This site — open case"
>
<div class="mp-card__body">
<h3 class="mp-card__title">This site</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">Built with AI IDE</li>
</ul>
<p class="mp-card__desc">Building this site with an AI IDE.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
+3 -3
View File
@@ -29,7 +29,7 @@
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/figma_logo.webp"
src="{{assetPrefix}}assets/misc/figma_logo.webp"
alt=""
width="800"
height="800"
@@ -59,7 +59,7 @@
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/dribbble_logo.webp"
src="{{assetPrefix}}assets/misc/dribbble_logo.webp"
alt=""
width="1024"
height="1024"
@@ -89,7 +89,7 @@
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/tg-channel-logo-design_not_pizza.webp"
src="{{assetPrefix}}assets/misc/tg-channel-logo-design_not_pizza.webp"
alt=""
width="640"
height="640"
+2 -2
View File
@@ -1,2 +1,2 @@
<script src="{{assetPrefix}}js/skill-connectors.js" defer></script>
<script type="module" src="{{assetPrefix}}js/main.js"></script>
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
+167
View File
@@ -0,0 +1,167 @@
<section id="projects" class="section section--surface" data-reveal>
<div class="section__inner">
<h2 class="section__title">Portfolio</h2>
<ol class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="ai-platform-primo-rpa">
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}ai-platform-primo-rpa/" aria-label="AI platform — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo AI Server</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="lunacy">
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}lunacy/" aria-label="Graphics editor Lunacy — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Lunacy</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="manu-game-maker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}manu-game-maker/" aria-label="MANU game engine — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">MANU game engine</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Desktop App</li>
</ul>
<p class="mp-card__desc">Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="mobile-clinic-app" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}mobile-clinic-app/" aria-label="Mobile app for a clinic network — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Mobile app for a clinic network</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Mobile App</li>
</ul>
<p class="mp-card__desc">Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="orc-automation-panel">
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}orc-automation-panel/" aria-label="Web panel for automation orchestration — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Primo Orc</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">Mentoring &amp; coaching</li>
</ul>
<p class="mp-card__desc">Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="task-distribution-concept" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}task-distribution-concept/" aria-label="Task distribution service concept — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task distribution service concept</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">UX/UI</li>
<li class="mp-card__tag">Analytics</li>
</ul>
<p class="mp-card__desc">A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2" data-related-slug="task-tracker" hidden>
<a class="mp-card mp-card--link mp-card--stack" href="{{casesPrefix}}task-tracker/" aria-label="Task tracker for a back-office system — open case">
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">Task tracker for a back-office system</h3>
<ul class="mp-card__tags" aria-label="Project type">
<li class="mp-card__tag">Product Design</li>
<li class="mp-card__tag">Web App</li>
</ul>
<p class="mp-card__desc">Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.</p>
</div>
</a>
</li>
</ol>
</div>
</section>
+235
View File
@@ -0,0 +1,235 @@
<section hidden class="section section--surface" aria-hidden="true">
<div class="section__inner">
<div class="projects__heading">
<h2 class="section__title projects__title">Portfolio</h2>
<div class="projects__controls" aria-label="Portfolio card navigation">
<button type="button" class="projects__nav-btn" data-projects-prev aria-label="Scroll left" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="projects__nav-btn" data-projects-next aria-label="Scroll right" aria-controls="projects-viewport">
<span class="projects__nav-ico projects__nav-ico--right" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="projects-carousel">
<div id="projects-viewport" class="projects-carousel__viewport" data-projects-viewport>
<div class="projects-carousel__track">
<div class="projects-carousel__spacer" aria-hidden="true"></div>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}orc-automation-panel/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Web panel for automation orchestration</h3>
<p class="portfolio-card__summary">
Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/orc-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}ai-platform-primo-rpa/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Platform for working with AI</h3>
<p class="portfolio-card__summary">
Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/ai-platform-primo-rpa/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}task-tracker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task tracker for a back-office system</h3>
<p class="portfolio-card__summary">
Interface for planning and distributing tasks in the operations department: shared pool, personal ToDo, per-assignee overview; tabs, filters, board / list / table views, drag &amp; drop.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Web App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/task-tracker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}manu-game-maker/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">MANU game engine</h3>
<p class="portfolio-card__summary">
Redesign and evolution of the game engine interface: a new working concept, refreshed visual system, and hundreds of complex user scenarios worked through.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">Product Design</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/manu-game-maker/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}lunacy/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Graphics editor Lunacy</h3>
<p class="portfolio-card__summary">
Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Desktop App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/lunacy/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}mobile-clinic-app/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Mobile app for a clinic network</h3>
<p class="portfolio-card__summary">
Design of a mobile app for booking doctors and managing medical services. Focus on a simple user path, clear navigation, and quick access to the right actions.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Mobile App</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/mobile-clinic-app/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}task-distribution-concept/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Task distribution service concept</h3>
<p class="portfolio-card__summary">
A product concept for a service to set, distribute and track tasks, with emphasis on process transparency and comfortable teamwork.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Analytics</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/task-distribution-concept/cover.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<article class="projects-carousel__item">
<a class="portfolio-card" href="{{casesPrefix}}obsidian-plugin-columns-view/">
<div class="portfolio-card__body">
<h3 class="portfolio-card__title">Columns view — plugin for Obsidian</h3>
<p class="portfolio-card__summary">
Interface for multi-column note work: convenient navigation, filtering and column-view settings right inside the Obsidian editor.
</p>
<ul class="portfolio-card__tags" aria-label="Key tags">
<li class="portfolio-card__tag">UX/UI</li>
<li class="portfolio-card__tag">Plugin</li>
</ul>
</div>
<div class="portfolio-card__cover" aria-hidden="true">
<img
class="portfolio-card__image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-defer-src="{{assetPrefix}}assets/obsidian-plugin-columns-view/filter use.webp"
alt=""
width="400"
height="225"
decoding="async"
/>
</div>
</a>
</article>
<div class="projects-carousel__spacer" aria-hidden="true"></div>
</div>
</div>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--left"
data-projects-edge-prev
aria-label="Scroll portfolio left"
></button>
<button
type="button"
class="projects-carousel__edge-hit projects-carousel__edge-hit--right"
data-projects-edge-next
aria-label="Scroll portfolio right"
></button>
</div>
</div>
</section>
+3 -3
View File
@@ -1,9 +1,9 @@
<script>
(function () {
var FONT_CSS = {
merriweather: "{{assetPrefix}}assets/fonts/merriweather.css",
geologica: "{{assetPrefix}}assets/fonts/geologica.css",
mono: "{{assetPrefix}}assets/fonts/mono.css",
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");