diff --git a/docs/mobile-production/fixes-20261016-member-spacing/README.md b/docs/mobile-production/fixes-20261016-member-spacing/README.md new file mode 100644 index 000000000..fd432b021 --- /dev/null +++ b/docs/mobile-production/fixes-20261016-member-spacing/README.md @@ -0,0 +1,28 @@ + + +# Отступ между навыками и кнопкой профиля + +16.10.2026. Ветка `fix/prod-member-card-spacing-20261016` от production master +`6f12e33579f8015504a26b27f3dd371a6b8b3707`. + +Причина: `margin-top:auto` у кнопки при автоматической высоте карточки давал 0px между навыками и действием. +Только в max-width:999px добавлены `margin-top:var(--ui-space-sm)` (8px) и `flex-shrink:0` для действия. +Шаблон, API, навыки/счётчик, ссылка и desktop стили сохранены. + +## Проверки и review + +- Checked by source: runtime diff — две строки SCSS внутри существующего мобильного media query. Review: actionable findings нет; исправление ограничено сообщённым дефектом. +- Checked in browser: 320/360/390/480/580/640/749/750/768/999px, по четыре карточки на ширине — 20 навыков (+19), четыре (+3), длинное имя/навык и пустые навыки. Было 0px, стало 8px. Кнопка высотой >=44px находится внутри карточки; две колонки и ссылки сохранены. +- Desktop 1000/1440/1920px: 3/3 точных RGB comparisons, 0 отличающихся пикселей. Исходный зазор 15px сохранён. +- Checked by tests: `npm run test:ci -- projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.spec.ts projects/social_platform/src/app/ui/pages/members/members.component.spec.ts` — 2 файла, 8 тестов, exit 0. Новые unit tests для двух строк CSS не добавлялись; регрессия проверяется в браузере. +- Checked by build: `npm run build:prod` — exit 0; прежние CommonJS warnings. Baseline и исправленная сборка используют одинаковый tracked sprite с symbol nodes; CSS/JS bundles не менялись после сборки. +- Static: Stylelint изменённого SCSS и `git diff --check` — exit 0; Prettier проверен для полного staged index с LF, как на CI. + +[До](./baseline-580.png) · [После](./verified-580.png) · [Метрики](./spacing-comparison.json) · [Desktop comparison](./desktop-comparison.json). + +Воспроизведение на synthetic API: `QA_BUILD_ROOT` указывает на соответствующий production build, +`PLAYWRIGHT_MODULE` и `CHROME_PATH` — как в [общем harness](../README.md#воспроизведение-browser-gate). +Запуск: `node docs/mobile-production/member-card-spacing.cjs baseline`, затем с исправленной сборкой `verified`. + +Not checked: физические устройства, Safari/WebKit и live API. Проверено в Chrome/Windows с synthetic API без live mutations. +Merge/deploy выполняет пользователь; миграции не требуются. diff --git a/docs/mobile-production/fixes-20261016-member-spacing/baseline-580.png b/docs/mobile-production/fixes-20261016-member-spacing/baseline-580.png new file mode 100644 index 000000000..a0e1051a8 Binary files /dev/null and b/docs/mobile-production/fixes-20261016-member-spacing/baseline-580.png differ diff --git a/docs/mobile-production/fixes-20261016-member-spacing/desktop-comparison.json b/docs/mobile-production/fixes-20261016-member-spacing/desktop-comparison.json new file mode 100644 index 000000000..e4f3b2f72 --- /dev/null +++ b/docs/mobile-production/fixes-20261016-member-spacing/desktop-comparison.json @@ -0,0 +1,26 @@ +[ + { + "screen": "members-spacing-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-spacing-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-spacing-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + } +] diff --git a/docs/mobile-production/fixes-20261016-member-spacing/spacing-comparison.json b/docs/mobile-production/fixes-20261016-member-spacing/spacing-comparison.json new file mode 100644 index 000000000..e44e3046e --- /dev/null +++ b/docs/mobile-production/fixes-20261016-member-spacing/spacing-comparison.json @@ -0,0 +1,93 @@ +[ + { + "width": 320, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 360, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 390, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 480, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 580, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 640, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 749, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 750, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 768, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 999, + "before": [0, 0, 0, 0], + "after": [8, 8, 8, 8], + "cards": 4, + "passed": true + }, + { + "width": 1000, + "before": [15, 15, 15, 15], + "after": [15, 15, 15, 15], + "cards": 4, + "passed": true + }, + { + "width": 1440, + "before": [15, 15, 15, 15], + "after": [15, 15, 15, 15], + "cards": 4, + "passed": true + }, + { + "width": 1920, + "before": [15, 15, 15, 15], + "after": [15, 15, 15, 15], + "cards": 4, + "passed": true + } +] diff --git a/docs/mobile-production/fixes-20261016-member-spacing/verified-580.png b/docs/mobile-production/fixes-20261016-member-spacing/verified-580.png new file mode 100644 index 000000000..c1e5ddc38 Binary files /dev/null and b/docs/mobile-production/fixes-20261016-member-spacing/verified-580.png differ diff --git a/docs/mobile-production/member-card-spacing.cjs b/docs/mobile-production/member-card-spacing.cjs new file mode 100644 index 000000000..4d52e341c --- /dev/null +++ b/docs/mobile-production/member-card-spacing.cjs @@ -0,0 +1,138 @@ +/** @format */ + +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const fixtures = require("./mobile-fixtures.cjs"); +const { launch, assertViewport } = require("./browser-harness.cjs"); +const preview = require("./preview.cjs"); +const [phase, widthsText = "320,360,390,480,580,640,749,750,768,999,1000,1440,1920", outputText] = + process.argv.slice(2); +const output = path.resolve(outputText || ".release-mobile/member-spacing-20261016/screenshots"); +const baseUrl = "http://127.0.0.1:4486"; +const original = fixtures.response; +const skill = { id: 1, name: "Apache Kafka", category: { id: 1, name: "Back-end" }, approves: [] }; +const variants = [ + { + firstName: "Сергей", + lastName: "Яксанов", + speciality: "Fullstack SWE", + count: 20, + name: "Apache Kafka", + }, + { + firstName: "Михаил", + lastName: "Хромов", + speciality: "Django developer", + count: 4, + name: "Python", + }, + { + firstName: "ОченьДлинноеИмяУчастника", + lastName: "ОченьДлиннаяФамилия", + speciality: "Проектирование распределённых систем и сервисов", + count: 1, + name: "ДлинныйНавыкБезПробеловДляПроверкиСжатияКарточки", + }, + { firstName: "Анна", lastName: "Иванова", speciality: "", count: 0 }, +]; +fixtures.response = (endpoint, method, query, state) => { + if (/\/auth\/public-users\/$/.test(endpoint)) + return { + count: variants.length, + next: null, + previous: null, + results: variants.map((v, i) => ({ + ...fixtures.user, + id: i + 10, + firstName: v.firstName, + lastName: v.lastName, + avatar: "/assets/images/profile/main.svg", + speciality: v.speciality, + skills: Array.from({ length: v.count }, (_, index) => ({ + ...skill, + id: index + 1, + name: index ? "Навык " + index : v.name, + })), + })), + }; + return original(endpoint, method, query, state); +}; + +(async () => { + fs.mkdirSync(path.join(output, phase), { recursive: true }); + const rows = []; + for (const width of widthsText.split(",").map(Number)) { + const h = await launch(width, { baseUrl, hasTouch: width < 1000, isMobile: width < 750 }); + await preview(h.context, baseUrl); + try { + const p = h.page; + await p.goto(baseUrl + "/office/members"); + await p.locator("app-member-card").nth(3).waitFor({ state: "visible" }); + await p.waitForLoadState("networkidle"); + await p.evaluate(async () => { + for (const image of document.images) image.loading = "eager"; + await document.fonts.ready; + await Promise.all([...document.fonts].map(font => font.load().catch(() => {}))); + await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + await p.addStyleTag({ + content: + "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}", + }); + const cards = await p.locator(".member-card").evaluateAll(elements => + elements.map(el => { + const box = node => { + const r = node.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + right: r.right, + bottom: r.bottom, + }; + }; + const action = el.querySelector(".member-card__action"), + skills = el.querySelector(".member-card__skills-area"); + return { + card: box(el), + action: box(action), + skills: box(skills), + gap: box(action).y - box(skills).bottom, + more: el.querySelector(".member-card__more")?.textContent.trim() || "", + url: el.getAttribute("href"), + }; + }), + ); + assert.equal(cards.length, 4); + assert.equal(cards[0].more, "+19"); + assert.equal(cards[1].more, "+3"); + if (phase !== "baseline" && width < 1000) { + await assertViewport(p); + assert.equal(cards[0].card.y, cards[1].card.y); + for (const [i, c] of cards.entries()) { + assert.ok(c.gap >= 7.99, `${width} card ${i}: gap ${c.gap}`); + assert.ok(c.action.height >= 44); + assert.ok(c.action.bottom <= c.card.bottom - 12); + assert.ok(c.action.x >= c.card.x && c.action.right <= c.card.right); + assert.equal(c.url, "/office/profile/" + (i + 10)); + } + } + assert.deepEqual(h.errors, []); + await p.screenshot({ + path: path.join(output, phase, `members-spacing-${width}.png`), + fullPage: true, + }); + rows.push({ width, cards, passed: true }); + console.log(`${phase}: ${width}, gaps ${cards.map(c => c.gap).join(", ")}`); + } finally { + await h.browser.close(); + } + } + fs.writeFileSync(path.join(output, `${phase}.json`), JSON.stringify(rows, null, 2) + "\n"); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss index 38c368e5d..a34f05589 100644 --- a/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss +++ b/projects/social_platform/src/app/ui/pages/members/member-card/member-card.component.scss @@ -185,7 +185,9 @@ } .member-card__action { + flex-shrink: 0; align-self: center; + margin-top: var(--ui-space-sm); } .member-card__skill {