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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/qa/program-management-v1/image-framing-20261017.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<!-- @format -->

# Настройка кадра программы и мобильная обложка

## Current / target

Организатор уже видит draft шапки и логотипа в preview desktop/mobile. Сейчас оба
режима используют один cover, object-fit cover/center; кадр и масштаб не редактируются.
Новый запрос напрямую расширяет существующий preview: изображение внутри кадра
можно двигать и масштабировать, мобильной обложке нужен отдельный файл.

Сохраняем Mont, DS buttons/fields, существующую форму и preview. Под переключателем
экрана — выбор «Обложка / Логотип», ползунок «Масштаб», кнопки точного сдвига и
«Сбросить кадр». Выбранное изображение можно перетаскивать мышью/пальцем и двигать
клавиатурой. Масштаб 100–300%; область кадра и положение круглого логотипа фиксированы.
Не добавляем отдельный modal или autosave. Это прямо запрошенная локальная итерация
существующего принятого UI; отдельное утверждение нового экрана не требуется.

Исходные файлы сохраняются. Настройки кадра — отдельно для desktop cover, mobile
cover и общий круг logo. Мобильная обложка необязательна: без неё берём основной
файл, но разрешаем отдельный мобильный кадр. Новая загрузка/удаление сбрасывают
соответствующий кадр. Если заменён основной файл и mobile использует fallback,
сбрасывается и мобильный кадр. Save и cancel сохраняют существующий lifecycle/409.

## Contract / roles

- Django PartnerProgram: nullable `mobile_cover_image_address`; JSON `image_framing`,
default `{}`. Новая additive migration0020; legacy выглядит как прежде.
- JSON `{cover:{x:50,y:50,zoom:1},mobile_cover:{...},logo:{...}}`;
x/y — положение в диапазоне0–100, zoom1–3. Сохраняем полный объект либо `{}` для
reset. Строгая валидация формы/ключей, конечные числа, запрет boolean/string/null.
- Existing PATCH `/programs/:id/management/settings/basic/`: оба новых поля optional,
остальной payload/expected_updated_at/atomic lock/permissions unchanged.
- GET management и public/member detail возвращают новые поля. List возвращает
`image_framing`, чтобы logo в списках/сайдбаре имел тот же кадр. Старые клиенты
игнорируют additive поля; frontend tolerates missing fields.
- Только текущие manager/staff по существующему can_manage_program меняют настройки;
anonymous/member/expert/чужой manager не получают write доступ.
- Сначала backend + migration на dev, затем frontend. Production вне scope.

## Acceptance criteria

| AC | Наблюдаемый результат |
| --- | -------------------------------------------------------------------------------------------------------------------------------- |
| F1 | Desktop/mobile cover и logo сдвигаются drag/keyboard/buttons, масштабируются100–300%, без пустых краёв и смены размера кадра |
| F2 | Новый mobile файл используется только на телефоне; без него — основной cover с независимым mobile кадром |
| F3 | Draft меняется до Save; после Save/reload реальные header и program logos повторяют crop; cancel восстанавливает сохранённое |
| F4 | Replace/remove/reset корректно сбрасывают кадр, failed upload и409 не теряют draft; mode/target не dirty-ят форму |
| F5 | Server rejects malformed/non-finite/out-of-range/unknown data атомарно; permissions иoptimistic lock действуют для новых полей |
| F6 | Legacy программы и profile/project avatars выглядят как прежде; no horizontal overflow, touch/keyboard usable, busy/error guards |

## Plan / verification

1. API model/migration/strict serializer/read contracts; PostgreSQL targeted tests,
public/member/list compatibility, permissions/stale/invalid payload.
2. Shared framing rendering primitive; opt-in Avatar framing, program Detail cover
and all existing program logo consumers. Unit regressions and Angular build.
3. Management draft control + mobile upload + preview drag/range/keyboard/reset;
preserve saving/cancel/guards. DOM integration + geometry tests.
4. Isolated Chrome against real local Django/PostgreSQL; mouse/touch, both sources,
save/reload/cancel/409/failure, actual-vs-preview framing and narrow viewport.
5. Requirement audit/code review; separate dev PRs, backend-first rollout/full CI,
live release smoke. Record real evidence and limitations below.

## Evidence

Review **PASS**: блокирующих замечаний нет. Проверены runtime diff, новые файлы,
API compatibility и opt-in Avatar rendering; исходники не переписываются.

| AC | Evidence | Verdict |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| F1 | Chrome mouse и native CDP touch; keyboard slider/Shift+arrows, unit clamps/reset/busy; кадр fixed/clipped, positions bounded | PASS |
| F2 | Реальный local multipart mobile upload, независимые transform и URL; fallback проверен до/после удаления | PASS |
| F3 | Local API не менялся до Save; Save/reload и точное совпадение computed framing, размеров, natural dimensions, источников с actual header в1440/360; sidebar logo | PASS |
| F4 | DOM upload/reset tests; browser cancel/failed upload413/concurrent PATCH409 без потери draft; toggles pristine | PASS |
| F5 | 48 API tests на local PostgreSQL, включая5 новых; invalid/finite/range/huge int/roles/stale/atomic update; final framing5PASS | PASS |
| F6 | Legacy Avatar/profile/project tests и source diff; mobile360/390 оба режима без overflow; page errors0; новые controls44px | PASS |

- Angular targeted tests/build/ESLint/Stylelint/format: результаты и full CI в PR.
- Local Django migration0020 применена; makemigrations --check — no changes.
-7 browser scenarios PASS на реальных local Django/PostgreSQL. Только внешний
object storage заменён local adapter. Один harness retry: native output также
имеет role=status, поэтому ожидание upload уточнено до p[role=status].
- Новый hint использует существующий token black для читаемого текста; Mont,
button foundation, spacing/radius и существующий preview сохранены.

[Desktop](image-framing-20261017/desktop.png), [mobile](image-framing-20261017/mobile.png),
[geometry/browser evidence](image-framing-20261017/browser-qa.json).

Not checked: физический телефон, OS screen reader, Selectel upload transport,
live organizer session. Native touch input проверен через Chrome CDP.
Backend PR [782](https://github.com/PROCOLLAB-github/api/pull/782); только dev rollout,
сначала API/migration, затем Angular. Live release checks будут в PR после deployment.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"checks": [
"Mode/target toggles keep form pristine; mobile uploader is optional and visible",
"Mouse drag and keyboard slider edit cover/logo draft without persisting program",
"Mobile fallback has independent crop; separate mobile upload resets only mobile crop and leaves desktop/logo unchanged",
"Save/reload roundtrip and real desktop/mobile header crop match preview exactly; sidebar logo uses saved framing",
"Cancel and failed upload preserve correct frame; real concurrent update returns409 without losing unsaved crop",
"Native touch drag, keyboard arrows and controls work at360/390px; both reference modes have no horizontal overflow",
"Removing mobile returns to main cover/default crop; removing all covers disables editing; cancel restores saved sources"
],
"errors": [],
"blockedExternal": [
"gc.kis.v2.scr.kaspersky-labs.com",
"fonts.googleapis.com",
"gc.kis.v2.scr.kaspersky-labs.com",
"fonts.googleapis.com",
"gc.kis.v2.scr.kaspersky-labs.com",
"fonts.googleapis.com"
],
"geometry": [
{
"device": "desktop",
"cover": {
"url": "https://example.com/preview-cover.svg",
"position": "60.68% 30.17%",
"origin": "525.887px 46.7635px",
"transform": "matrix(1.65, 0, 0, 1.65, 0, 0)",
"natural": [1800, 320],
"width": 867,
"height": 155
},
"logo": {
"url": "https://example.com/preview-logo.svg",
"position": "55.5% 35.33%",
"origin": "67.155px 42.7493px",
"transform": "matrix(1.5, 0, 0, 1.5, 0, 0)",
"natural": [600, 300],
"width": 121,
"height": 121
}
},
{
"device": "mobile",
"cover": {
"url": "https://example.com/local-material-mobile-1792253248780.svg",
"position": "68.29% 21.29%",
"origin": "223.991px 28.9544px",
"transform": "matrix(1.25, 0, 0, 1.25, 0, 0)",
"natural": [960, 400],
"width": 328,
"height": 136
},
"logo": {
"url": "https://example.com/preview-logo.svg",
"position": "55.5% 35.33%",
"origin": "57.165px 36.3899px",
"transform": "matrix(1.5, 0, 0, 1.5, 0, 0)",
"natural": [600, 300],
"width": 103,
"height": 103
}
}
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @format */

export interface ProgramImageFrame {
x: number;
y: number;
zoom: number;
}
export type ProgramImageKey = "cover" | "mobileCover" | "logo";
export type ProgramImageFraming = Partial<Record<ProgramImageKey, ProgramImageFrame>>;
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** @format */

import { ProgramImageFraming } from "./program-image-framing.model";

export type ManagementSection =
| "participants"
| "projects"
Expand All @@ -21,6 +23,8 @@ export interface ManagementBasic {
description: string | null;
imageAddress: string | null;
coverImageAddress: string | null;
mobileCoverImageAddress?: string | null;
imageFraming?: ProgramImageFraming;
advertisementImageAddress: string | null;
registrationLink: string | null;
presentationAddress: string | null;
Expand Down Expand Up @@ -112,7 +116,7 @@ export interface ManagementQuery {
export interface ManagementSettingsWrite {
expectedUpdatedAt: string;
confirmReopen?: boolean;
[field: string]: string | boolean | null | undefined;
[field: string]: string | boolean | null | undefined | ProgramImageFraming;
}
export interface ManagementMaterialWrite {
title: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** @format */

import { ProgramImageFraming } from "./program-image-framing.model";

export interface ProgramCurrentApplication {
projectId: number;
programLinkId: number;
Expand All @@ -11,6 +13,8 @@ export class Program {
id!: number;
imageAddress!: string;
coverImageAddress!: string;
mobileCoverImageAddress?: string | null;
imageFraming?: ProgramImageFraming;
presentationAddress!: string;
advertisementImageAddress!: string;
name!: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
<div class="program">
<div class="program__top">
<div class="program__info">
<app-avatar [size]="30" [url]="program().imageAddress"></app-avatar>
<app-avatar
[size]="30"
[url]="program().imageAddress"
[imageFraming]="program().imageFraming?.logo ?? null"
></app-avatar>
<p class="text-body-10">до {{ program().datetimeRegistrationEnds | date: "dd.MM.yy" }}</p>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
@if (program()) {
<div class="card">
<div class="card__photo">
<app-avatar [url]="program().imageAddress" [size]="100"></app-avatar>
<app-avatar
[url]="program().imageAddress"
[imageFraming]="program().imageFraming?.logo ?? null"
[size]="100"
></app-avatar>
</div>

<div class="card__info">
Expand Down Expand Up @@ -47,7 +51,11 @@
@if (program()) {
<div appCard [interactive]="true" class="card">
<div class="card__photo">
<app-avatar [url]="program().imageAddress" [size]="64"></app-avatar>
<app-avatar
[url]="program().imageAddress"
[imageFraming]="program().imageFraming?.logo ?? null"
[size]="64"
></app-avatar>
</div>

<div class="card__info">
Expand Down
Loading