Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
98 changes: 98 additions & 0 deletions docs/responsive/CHECKLIST.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions docs/responsive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!-- @format -->

# Responsive PROCOLLAB

Работа выполнена в `feat/dev-platform-responsive` от `origin/dev` (`9acb7ce2`). Основное приложение — Angular `social_platform`; API не изменён. Существующие URL, роли, ограничения редактирования и отправки сохранены.

## Общая система

- `styles/_responsive.scss`: прежние границы tablet 750 / desktop 1000, общие mixins `single-column`, `scroll-tabs`, `mobile-actions`, `apply-mobile`, `apply-below-desktop`.
- `styles/_profile-form.scss` и `styles/_course-task.scss`: общая адаптация шагов профиля и разных видов заданий курса, без копирования одинаковых overrides по экранам.
- `styles/_platform-responsive.scss`: intrinsic sizing, перенос контента, mobile typography, локальный скролл таблиц, sticky header/actions для новых диалогов, правильный слой CDK popup внутри modal.
- Общие Button/Input/Select/Textarea/Autocomplete/Dropdown/Modal/Tooltip/File/Checkbox/Switch: поля и touch-зоны, перенос текста, ограничения viewport. Autocomplete использует CDK connected overlay; подсказка открывается нажатием и клавиатурой.
- AppComponent обновляет CSS-переменные visualViewport; используется `dvh` с fallback. Диалоги учитывают изменение видимой высоты, safe area и прокрутку содержимого.
- Drawer закрывается при переходе, Escape, нажатии на фон и смене ширины на desktop; возвращает фокус и освобождает скролл фона.
- Формы проекта/профиля и onboarding перестраиваются в одну колонку; tabs прокручиваются локально; карточки и команда занимают доступную ширину. Виджет роли мероприятия доступен на mobile.

Аудит также обнаружил существующие препятствия пользовательским сценариям: dashboard при прямом входе читал отсутствующий профиль, подтверждение сброса пароля не имело провайдера формы, stage-0 onboarding не загружал профиль, stage-1 повторно публиковал восстановленный черновик. Исправления ограничены загрузкой/инициализацией UI; предусмотрены regression tests. В Windows исправлено экранирование glob генератора SVG sprite: иконки теперь включаются в production build.

## Проверки

[Checklist](CHECKLIST.md) содержит все декларации router и результаты Desktop → Tablet → 390 → 320. [browser-results.json](browser-results.json) хранит измерения и фактически отрисованные компоненты; [flow-results.json](flow-results.json) — действия и перехваченные тестовые запросы. [screenshots](screenshots/) содержит mobile/desktop примеры и диалоги.

Ширины: 320, 360, 375, 390, 430, 768, 820, 1024, 1280, 1440, 1920. Проверяются длинные строки без пробелов, ФИО/email/навыки/названия файлов, локальный скролл и размер страницы. Проверяются `pageerror` и `console.error`. Начальная структурная инвентаризация сохранена в `routes.json`.

Функциональные проверки в Chrome с touch/mobile viewport 320 и 390: регистрация, вход/drawer, поля профиля/select/datepicker/autocomplete/сохранение, мероприятие → заявка → команда/приглашение → заполнение → подтверждение отправки, эксперт → оценка → подтверждение, поиск вакансии → отклик с файлом → мои отклики, создание проекта/черновик менеджером → принятие отклика, onboarding с образованием/достижением → следующий шаг, ответ в курсе → результаты → курс, single/multiple choice и ответы с файлами, таблицы аналитики/кейсы/подсказки. Проверены 12 длинных навыков, длинный email, пустой поиск, ошибки регистрации, загрузка и неверный ответ курса. Для диалогов приглашения и отклика дополнительно проверена высота viewport 480 px.

Production build успешен. Полный Vitest: 405 файлов, 1997 тестов. ESLint изменённых TypeScript и Stylelint изменённых SCSS проходят. В build остаются существующие предупреждения CommonJS и CSS budget; тесты могут печатать существующий NG0912 двух IconComponent. Матрица содержит 627 измерений на всех 11 ширинах и 30 дополнительных на 749/750/999/1000/1001 px; функциональные проверки — 24 запуска на 320/390 px.

После финальной обработки resize в drawer повторно прошли 3 теста office и оба сценария навигации. Логи сборки и тестов сохранены в `verification/`.

## Воспроизведение

Требуется установленный Playwright и Chrome. Можно использовать модуль Playwright из bundled Codex runtime, задав `PLAYWRIGHT_MODULE` абсолютным путём; `CHROME_PATH` указывает на Chrome. Новая зависимость в package.json не добавлялась.

```powershell
npm ci
npm run build:social:prod
node docs/responsive/serve.cjs
# В другом терминале:
$env:PLAYWRIGHT_MODULE = 'абсолютный путь к модулю playwright'
$env:CHROME_PATH = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
node docs/responsive/browser-check.cjs
node docs/responsive/flow-check.cjs
node docs/responsive/report.cjs
```

Все внешние запросы перехватываются. API-фикстуры полностью синтетические; регистрация, приглашения, отправка проекта и оценки не затрагивают живую базу. Число rows сверяется, а процесс завершается с ошибкой при переполнениях или ошибках браузера.

## Границы проверки

Chrome automation проверяет layout и работу UI с API-контрактами. Доставка писем, сохранение на настоящем backend, ограничения конкретного аккаунта, настоящий WebSocket и внешний admin не проверялись. Не созданы отсутствующие интерфейсы администратора или редактирования мероприятий.

Перед выпуском остаётся проверка на физических Safari iOS и Chrome Android: экранная клавиатура в длинных формах/диалогах, browser toolbar, safe area в portrait/landscape, native picker файлов и даты. Изменение viewport в headless Chrome проверено, но не заменяет эту проверку устройств.
104 changes: 104 additions & 0 deletions docs/responsive/audit.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/** @format */
// Inventory the real router tree, including lazy modules, without executing guards or API calls.
const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
const root = path.resolve(__dirname, "../..");
const app = path.join(root, "projects/social_platform/src/app");
const inventory = [];
const visited = new Set();
const read = file =>
ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
const resolve = (from, name) => {
if (name.startsWith(".")) return path.resolve(path.dirname(from), name) + ".ts";
if (name.startsWith("@ui/")) return path.join(app, "ui", name.slice(4)) + ".ts";
};
function walk(file, prefix = "", inherited = []) {
const key = file + ":" + prefix;
if (visited.has(key)) return;
visited.add(key);
const source = read(file);
const imports = new Map();
for (const statement of source.statements) {
if (
ts.isImportDeclaration(statement) &&
statement.importClause?.namedBindings &&
ts.isNamedImports(statement.importClause.namedBindings)
) {
for (const item of statement.importClause.namedBindings.elements)
imports.set(item.name.text, resolve(file, statement.moduleSpecifier.text));
}
}
const literal = node => (node && ts.isStringLiteral(node) ? node.text : "");
const processRoutes = (array, base, parents) => {
for (const route of array.elements) {
if (!ts.isObjectLiteralExpression(route)) continue;
const props = new Map(
route.properties
.filter(ts.isPropertyAssignment)
.map(p => [p.name.getText(source), p.initializer]),
);
const url = (base + "/" + literal(props.get("path"))).replace(/\/+/g, "/") || "/";
const component = props.get("component")?.getText(source);
const chain = [...parents, ...(component ? [component] : [])];
if (component || props.has("redirectTo")) {
const componentFile = imports.get(component);
const scss = componentFile?.replace(/\.ts$/, ".scss");
const styles = scss && fs.existsSync(scss) ? fs.readFileSync(scss, "utf8") : "";
const issues = [];
if (/min-width:\s*(?:[2-9]\d{2}|\d{4})px/.test(styles)) issues.push("fixed minimum width");
if (/100vh/.test(styles)) issues.push("legacy viewport height");
if (/grid-template-columns:.*(?:[2-9]fr|repeat\([2-9])/.test(styles))
issues.push("multi-column layout");
inventory.push({
route: url,
component,
layout: chain,
source: path.relative(root, file).replaceAll("\\", "/"),
componentSource:
componentFile && path.relative(root, componentFile).replaceAll("\\", "/"),
redirect: literal(props.get("redirectTo")) || undefined,
issues,
});
}
const children = props.get("children");
if (children && ts.isArrayLiteralExpression(children)) processRoutes(children, url, chain);
const lazy = props.get("loadChildren");
if (lazy) {
let module;
const visit = node => {
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword)
module = literal(node.arguments[0]);
ts.forEachChild(node, visit);
};
visit(lazy);
if (module) walk(resolve(file, module), url, chain);
}
}
};
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (declaration.initializer && ts.isArrayLiteralExpression(declaration.initializer))
processRoutes(declaration.initializer, prefix, inherited);
}
}
}
walk(path.join(app, "app.routes.ts"));
fs.writeFileSync(path.join(__dirname, "routes.json"), JSON.stringify(inventory, null, 2) + "\n");
const screens = inventory.filter(row => row.component);
console.log(
JSON.stringify(
{
screens: screens.length,
redirects: inventory.length - screens.length,
routes: screens.map(row => ({
route: row.route,
component: row.component,
issues: row.issues,
})),
},
null,
2,
),
);
109 changes: 109 additions & 0 deletions docs/responsive/browser-check.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/** @format */
const { launch, measure } = require("./browser-harness.cjs");
const fs = require("node:fs");
const path = require("node:path");
const base = "http://127.0.0.1:4360";
const output = __dirname;
const screenshotOutput = path.resolve(output, process.env.RESPONSIVE_SCREENSHOTS || "screenshots");
const widths = process.env.RESPONSIVE_WIDTHS?.split(",").map(Number) || [
320, 360, 375, 390, 430, 768, 820, 1024, 1280, 1440, 1920,
];
const inventory = require("./routes.json");
const urls = process.env.RESPONSIVE_ROUTES?.split(",") || [
...new Set(
inventory
.filter(r => r.component && !r.route.endsWith("/") && !r.route.includes("**"))
.map(r => r.route.replace(/:[^/]+/g, "1")),
),
"/office/projects/1",
"/office/program/1",
"/office/profile/1",
"/office/vacancies/1",
"/office/vacancies/my",
"/office/courses/1",
"/office/feed",
];
if (!process.env.RESPONSIVE_ROUTES) {
urls.push(
...["main", "education", "experience", "achievements", "skills", "settings"].map(
step => `/office/profile/edit?editingStep=${step}`,
),
);
urls.push(
...["main", "contacts", "achievements", "vacancies", "team", "additional"].map(
step => `/office/projects/1/edit?editingStep=${step}&programLinkId=1`,
),
);
}
const resetIndex = urls.indexOf("/auth/reset_password");
if (resetIndex >= 0) urls[resetIndex] += "?token=responsive-fixture";
(async () => {
const { browser, page, requests, errors } = await launch();
const rows = [];
for (const width of widths) {
await page.setViewportSize({ width, height: width >= 750 ? 1000 : 844 });
for (const url of urls) {
const errorStart = errors.length;
await page.goto(base + url);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(150);
const result = await measure(page);
if (
url === "/office/onboarding/stage-0" &&
!(await page.locator("app-stage-zero .page__form input").count())
)
errors.push("Stage zero form is empty");
if (
url.endsWith("/lesson/1/results") &&
url.includes("/courses/1/") &&
!(await page.locator("app-complete app-button").isVisible())
)
errors.push("Lesson completion CTA is missing");
rows.push({ route: url, width, ...result, errors: errors.slice(errorStart) });
if (
[320, 390, 1440].includes(width) &&
[
"/auth/login",
"/office/projects/my",
"/office/program/1/analytics",
"/office/profile/edit",
"/office/program/1",
"/office/onboarding/stage-0",
"/office/courses/1/lesson/1/results",
].includes(url)
) {
fs.mkdirSync(screenshotOutput, { recursive: true });
await page.screenshot({
path: path.join(screenshotOutput, `${url.replaceAll("/", "-").slice(1)}-${width}.png`),
fullPage: true,
});
}
console.log(
JSON.stringify({
route: url,
width,
actualPath: result.actualPath,
overflow: result.overflows.length,
errors: errors.length - errorStart,
}),
);
}
}
fs.writeFileSync(
path.join(output, process.env.RESPONSIVE_OUTPUT || "browser-results.json"),
JSON.stringify({ rows, requests: [...requests], errors }, null, 2),
);
await browser.close();
const failures = rows.filter(
row =>
row.overflows.length ||
row.errors.length ||
row.pageWidth > row.width + 1 ||
!row.text.trim(),
);
console.log(JSON.stringify({ checks: rows.length, failures: failures.length }));
if (failures.length) process.exitCode = 1;
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
Loading