# Sprint 6 — P3 Polish + Cleanup Tail Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Закрыть P3-эпики финального спринта portal-audit (`docs/superpowers/specs/2026-05-15-portal-audit-design.md` §3 Sprint 6) — a11y-доводка, гигиена констант, снятие устаревшего dev-workaround'а.

**Architecture:** 5 независимых XS-эпиков, все — frontend-only (Vue 3.5 SFC + TypeScript), 0 backend / 0 schema / 0 миграций. Каждый эпик правит обособленную группу файлов — пересечений между задачами нет, порядок T1→T5 произвольный, конфликты исключены.

**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (тесты), `import.meta.env.DEV` (DEV-гейт, статически вырезается Vite в prod-сборке).

**Scope-решение по 3 эпикам Sprint 6, НЕ входящим в этот план:**

- **F5** (`new_device_login` через session-fingerprint) — XL, требует инфраструктуры сессий-фингерпринтов; вне MVP, остаётся в реестре.
- **G8** (ImpersonationDialog two-person approval, CTO-15) — требует Б-1 (Yandex 360 SSO); блокирован внешним блокером.
- **I2** (dev-indices.json — gitignore-решение) — **отложен вместе с I1**. I1 (снос DevIndexBadge/DevIndexOverlay) заказчик отложил в Sprint 5D до заморозки UI; `dev-indices.json` — генерируемый манифест этой же временной фичи. Решать его git-судьбу до сноса фичи — churn на артефакте, который всё равно удалится. Отложен синхронно с I1.

**Эпики в этом плане (5):** A9, B6, F4, G9, I5.

---

## File Structure

| Файл | Задача | Ответственность |
|---|---|---|
| `app/resources/js/views/auth/LoginView.vue` | T1 (A9) | eye-toggle через `#append-inner` slot с accessible-name |
| `app/resources/js/views/auth/RegisterView.vue` | T1 (A9) | то же |
| `app/resources/js/views/auth/ResetPasswordView.vue` | T1 (A9) | то же (только первое поле пароля; поле «Повторите» eye-иконки не имеет) |
| `app/resources/js/layouts/AdminLayout.vue` | T2 (B6) | DEV-only баннер о застабленном auth-gate |
| `app/resources/js/constants/polling.ts` | T3 (F4) | **создаётся** — именованные интервалы polling |
| `app/resources/js/composables/usePolling.ts` | T3 (F4) | дефолт интервала из константы |
| `app/resources/js/layouts/AppLayout.vue` | T3 (F4) | call-site → константы |
| `app/resources/js/components/admin/ImpersonationBanner.vue` | T3 (F4) | call-site → константа |
| `app/resources/js/views/ReportsView.vue` | T3 (F4) | call-site → константа |
| `app/resources/js/views/admin/AdminSystemView.vue` | T4 (G9) | aria-label на edit-кнопки |
| `app/resources/js/views/ProjectsView.vue` | T5 (I5) | удаление устаревшего clearable-workaround'а из `<style>` |
| spec-файлы в `app/tests/Frontend/` | T1/T2/T4 | failing-тесты + сверка существующих |

---

## Task 1: A9 — accessible-name на eye-icon переключателях пароля

**Контекст:** В `LoginView`/`RegisterView`/`ResetPasswordView` поле пароля переключает видимость через Vuetify-проп `:append-inner-icon` + `@click:append-inner`. Иконка-переключатель кликабельна, но не имеет accessible-name и не доступна с клавиатуры → screen-reader пользователь не знает, что это кнопка. Фикс — заменить проп на слот `#append-inner` с `<v-icon>` в роли кнопки: `role="button"` + `tabindex` + `:aria-label` + keyboard-обработчики.

**Files:**

- Modify: `app/resources/js/views/auth/LoginView.vue:81-93`
- Modify: `app/resources/js/views/auth/RegisterView.vue:97-109`
- Modify: `app/resources/js/views/auth/ResetPasswordView.vue:107-119`
- Test: `app/tests/Frontend/LoginView.spec.ts`, `RegisterView.spec.ts`, `ResetPasswordView.spec.ts`

- [ ] **Step 1: Сверить существующие spec-файлы**

Прочитать 3 spec-файла. Найти тесты, взаимодействующие с переключателем (grep `append-inner`, `showPassword`, `eye`). Если тест триггерит `click:append-inner` — после рефактора это событие не возникнет (теперь клик по `<v-icon>` в слоте), такие тесты переписать на клик по иконке с aria-label. Запомнить mount-setup (плагины Vuetify/Pinia/router-стабы) для нового теста.

- [ ] **Step 2: Написать failing-тест (для каждой из 3 вью)**

В каждый spec добавить тест (mount-setup взять из существующих тестов файла):

```ts
it('переключатель видимости пароля имеет accessible-name и работает', async () => {
    const wrapper = mount(LoginView, { /* global: из существующего setup файла */ });
    const toggle = wrapper.find('[aria-label="Показать пароль"]');
    expect(toggle.exists()).toBe(true);
    expect(toggle.attributes('role')).toBe('button');
    await toggle.trigger('click');
    expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
});
```

Для `RegisterView`/`ResetPasswordView` — аналогично, заменив компонент.

- [ ] **Step 3: Прогнать тесты — убедиться, что падают**

Run: `cd app && npm run test:vue -- LoginView RegisterView ResetPasswordView`
Expected: 3 новых теста FAIL (`aria-label="Показать пароль"` не найден — сейчас проп `append-inner-icon`).

- [ ] **Step 4: Реализовать слот в LoginView.vue**

Заменить блок `app/resources/js/views/auth/LoginView.vue:81-93` (поле пароля):

```vue
            <v-text-field
                v-model="password"
                label="Пароль"
                :type="showPassword ? 'text' : 'password'"
                autocomplete="current-password"
                placeholder="Минимум 8 символов"
                variant="outlined"
                density="comfortable"
                required
                :error-messages="errors.password"
            >
                <template #append-inner>
                    <v-icon
                        :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
                        :aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
                        role="button"
                        tabindex="0"
                        @click="showPassword = !showPassword"
                        @keydown.enter.prevent="showPassword = !showPassword"
                        @keydown.space.prevent="showPassword = !showPassword"
                    />
                </template>
            </v-text-field>
```

- [ ] **Step 5: Реализовать слот в RegisterView.vue**

Заменить блок `app/resources/js/views/auth/RegisterView.vue:97-109` тем же паттерном (поле пароля, `autocomplete="new-password"` — сохранить, `placeholder="Минимум 8 символов"` — сохранить). Снять строки `:append-inner-icon="..."` и `@click:append-inner="..."`, добавить `#append-inner`-слот как в Step 4.

- [ ] **Step 6: Реализовать слот в ResetPasswordView.vue**

Заменить блок `app/resources/js/views/auth/ResetPasswordView.vue:107-119` (первое поле — «Новый пароль») тем же паттерном. **Поле «Повторите пароль» (`:122-130`) не трогать** — у него нет eye-иконки, оно наследует `showPassword`.

- [ ] **Step 7: Прогнать тесты — убедиться, что зелёные**

Run: `cd app && npm run test:vue -- LoginView RegisterView ResetPasswordView`
Expected: PASS — новые 3 теста + все ранее существовавшие в этих файлах.

- [ ] **Step 8: Commit**

```bash
git add app/resources/js/views/auth/LoginView.vue app/resources/js/views/auth/RegisterView.vue app/resources/js/views/auth/ResetPasswordView.vue app/tests/Frontend/LoginView.spec.ts app/tests/Frontend/RegisterView.spec.ts app/tests/Frontend/ResetPasswordView.spec.ts
git commit -m "fix(a11y): accessible eye-toggle на полях пароля — Sprint 6 A9"
```

---

## Task 2: B6 — DEV-only баннер о застабленном auth-gate админки

**Контекст:** Sprint 3F (J2) поставил middleware `EnsureSaasAdmin` на `/api/admin/*` как стаб: в dev пропускает все запросы, в prod отдаёт 503. Комментарий в шапке `AdminLayout.vue:9-12` фиксирует, что полноценный auth-guard (`super_admin` role + 2FA через Yandex 360 SSO) ждёт Б-1. B6 — сделать этот auth-gap видимым в dev-UI баннером. Гейт — `import.meta.env.DEV` (Vite статически вырежет баннер в prod-сборке, паттерн I4 из Sprint 5D).

**Files:**

- Modify: `app/resources/js/layouts/AdminLayout.vue` (script + template)
- Test: `app/tests/Frontend/AdminLayout.spec.ts`

- [ ] **Step 1: Сверить AdminLayout.spec.ts**

Прочитать spec — запомнить mount-setup. Проверить наличие `vi.unstubAllEnvs()` в `afterEach` (если нет — добавить в Step 2, иначе stub `DEV` протечёт в другие тесты).

- [ ] **Step 2: Написать failing-тест**

В `AdminLayout.spec.ts` добавить (mount-setup — из существующих тестов):

```ts
it('B6: показывает DEV-баннер auth-gap в dev-режиме', () => {
    const wrapper = mount(AdminLayout, { /* global: из существующего setup */ });
    expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(true);
});

it('B6: скрывает DEV-баннер в production-режиме', () => {
    vi.stubEnv('DEV', false);
    const wrapper = mount(AdminLayout, { /* global: из существующего setup */ });
    expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(false);
});
```

Убедиться, что в файле есть `afterEach(() => { vi.unstubAllEnvs(); });` (добавить, если отсутствует).

- [ ] **Step 3: Прогнать тест — убедиться, что падает**

Run: `cd app && npm run test:vue -- AdminLayout`
Expected: тест `показывает DEV-баннер` FAIL (`data-testid="dev-auth-gap-banner"` не найден).

- [ ] **Step 4: Добавить DEV-флаг в script**

В `app/resources/js/layouts/AdminLayout.vue` после `const auth = useAuthStore();` (`:39`) добавить:

```ts

/** DEV-режим: показываем баннер о застабленном auth-gate админки (B6). */
const isDevEnv = import.meta.env.DEV;
```

- [ ] **Step 5: Добавить баннер в template**

В `<v-main class="admin-main">` (`:133`) — перед `<ImpersonationBanner />` вставить:

```vue
            <v-alert
                v-if="isDevEnv"
                type="warning"
                variant="tonal"
                density="compact"
                class="ma-4"
                data-testid="dev-auth-gap-banner"
            >
                DEV-режим: доступ к админке открыт без SSO-проверки — middleware
                <code>EnsureSaasAdmin</code> в dev пропускает все запросы. В production
                требуется вход через Yandex 360 + роль <code>super_admin</code> (Б-1);
                неавторизованные запросы получают 503.
            </v-alert>
```

- [ ] **Step 6: Прогнать тест — убедиться, что зелёные**

Run: `cd app && npm run test:vue -- AdminLayout`
Expected: PASS — оба новых теста + ранее существовавшие.

- [ ] **Step 7: Commit**

```bash
git add app/resources/js/layouts/AdminLayout.vue app/tests/Frontend/AdminLayout.spec.ts
git commit -m "feat(admin): DEV-only баннер о застабленном auth-gate — Sprint 6 B6"
```

---

## Task 3: F4 — вынести polling-интервалы в `constants/polling.ts`

**Контекст:** Дефолтный интервал `30_000` зашит в `usePolling.ts`, а call-site'ы `AppLayout`/`ImpersonationBanner`/`ReportsView` дублируют литералы `30_000`/`60_000`. F4 — собрать «магические» числа в один модуль. Чистый рефактор: поведение не меняется, защитная сетка — существующие тесты.

**Files:**

- Create: `app/resources/js/constants/polling.ts`
- Modify: `app/resources/js/composables/usePolling.ts:18,25`
- Modify: `app/resources/js/layouts/AppLayout.vue:17,60,61`
- Modify: `app/resources/js/components/admin/ImpersonationBanner.vue:16,40`
- Modify: `app/resources/js/views/ReportsView.vue:14,62`

- [ ] **Step 1: Создать `constants/polling.ts`**

```ts
/**
 * Интервалы polling-обновления view-данных — единый источник «магических»
 * чисел для usePolling. До приезда SSE/WebSocket в production это покрывает
 * «real-time»-паттерн (см. composables/usePolling.ts).
 */

/** Базовый интервал авто-обновления (сделки, биллинг, инциденты, тенанты, отчёты). */
export const POLLING_INTERVAL_MS = 30_000;

/** Интервал для менее срочных счётчиков (напоминания в сайдбаре). */
export const POLLING_REMINDERS_INTERVAL_MS = 60_000;
```

- [ ] **Step 2: Подключить константу в usePolling.ts**

В `app/resources/js/composables/usePolling.ts`:

- Первой строкой добавить импорт: `import { POLLING_INTERVAL_MS } from '../constants/polling';` (после `import { onBeforeUnmount, onMounted } from 'vue';`).
- Строка `:18` doc-комментарий: `/** Период polling в миллисекундах. По умолчанию 30_000. */` → `/** Период polling в миллисекундах. По умолчанию POLLING_INTERVAL_MS (30 с). */`
- Строка `:25`: `const intervalMs = options.intervalMs ?? 30_000;` → `const intervalMs = options.intervalMs ?? POLLING_INTERVAL_MS;`

- [ ] **Step 3: Обновить call-site'ы**

`AppLayout.vue` — добавить к импортам (`:17`): `import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';`

- `:60` `usePolling(loadNotifications, { intervalMs: 30_000, enabled: true });` → `{ intervalMs: POLLING_INTERVAL_MS, enabled: true }`
- `:61` `usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });` → `{ intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true }`

`ImpersonationBanner.vue` — добавить импорт `import { POLLING_INTERVAL_MS } from '../../constants/polling';`

- `:40` `usePolling(load, { intervalMs: 30_000 });` → `{ intervalMs: POLLING_INTERVAL_MS }`

`ReportsView.vue` — добавить импорт `import { POLLING_INTERVAL_MS } from '../constants/polling';`

- `:62` `usePolling(loadJobs, { intervalMs: 30_000 });` → `{ intervalMs: POLLING_INTERVAL_MS }`

Call-site'ы на дефолте (`DealsView`/`KanbanView`/`AdminBillingView`/`AdminIncidentsView`/`AdminTenantsView`) — **не трогать**, они уже получают значение через дефолт `usePolling`.

- [ ] **Step 4: Type-check + тесты (рефактор — поведение без изменений)**

Run: `cd app && npm run type-check && npm run test:vue -- usePolling AppLayout ImpersonationBanner ReportsView`
Expected: vue-tsc 0 ошибок; все тесты PASS без изменений (интервалы численно те же — `30_000`/`60_000`).

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/constants/polling.ts app/resources/js/composables/usePolling.ts app/resources/js/layouts/AppLayout.vue app/resources/js/components/admin/ImpersonationBanner.vue app/resources/js/views/ReportsView.vue
git commit -m "refactor(polling): вынести интервалы в constants/polling.ts — Sprint 6 F4"
```

---

## Task 4: G9 — aria-label на edit-кнопки AdminSystemView

**Контекст:** В списке `system_settings` каждая строка имеет кнопку «Изменить» (`AdminSystemView.vue:166-175`). У всех кнопок одинаковый видимый текст «Изменить» — screen-reader пользователь, проходя список, слышит «Изменить, Изменить, Изменить» без контекста, какая настройка. Фикс — `:aria-label` с ключом настройки.

**Files:**

- Modify: `app/resources/js/views/admin/AdminSystemView.vue:166-175`
- Test: `app/tests/Frontend/AdminSystemView.spec.ts`

- [ ] **Step 1: Написать failing-тест**

В `AdminSystemView.spec.ts` добавить (mount-setup — из существующих тестов файла; компонент стартует с mock-данными до `onMounted`-загрузки, либо использовать `defineExpose`d `settingsState`):

```ts
it('G9: edit-кнопки имеют aria-label с ключом настройки', () => {
    const wrapper = mount(AdminSystemView, { /* global: из существующего setup */ });
    const editBtns = wrapper.findAll('[data-testid^="edit-"]');
    expect(editBtns.length).toBeGreaterThan(0);
    for (const btn of editBtns) {
        const label = btn.attributes('aria-label') ?? '';
        expect(label).toMatch(/^Изменить настройку .+/);
    }
});
```

- [ ] **Step 2: Прогнать тест — убедиться, что падает**

Run: `cd app && npm run test:vue -- AdminSystemView`
Expected: FAIL — `aria-label` отсутствует на кнопках.

- [ ] **Step 3: Добавить aria-label**

В `app/resources/js/views/admin/AdminSystemView.vue` в `<v-btn>` (`:166-175`) добавить строку `:aria-label` между `prepend-icon` и `:data-testid`:

```vue
                        <v-btn
                            variant="text"
                            size="small"
                            density="comfortable"
                            prepend-icon="mdi-pencil"
                            :aria-label="`Изменить настройку ${setting.key}`"
                            :data-testid="`edit-${setting.key}-btn`"
                            @click="openEdit(setting)"
                        >
                            Изменить
                        </v-btn>
```

- [ ] **Step 4: Прогнать тест — убедиться, что зелёный**

Run: `cd app && npm run test:vue -- AdminSystemView`
Expected: PASS — новый тест + ранее существовавшие.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/views/admin/AdminSystemView.vue app/tests/Frontend/AdminSystemView.spec.ts
git commit -m "fix(a11y): aria-label с ключом на edit-кнопках AdminSystem — Sprint 6 G9"
```

---

## Task 5: I5 — снять устаревший clearable-workaround из ProjectsView

**Контекст:** `ProjectsView.vue:170-196` содержит CSS-workaround: у `clearable` `v-text-field` иконка `mdi-close-circle` делалась прозрачной, а вместо неё `::after`-псевдоэлементом рисовался Unicode-глиф `✕` — потому что MDI-шрифт не был подключён (Диз-4). CTO-19 (миграция на Lucide) закрыта: `app/resources/js/plugins/vuetify.ts:164` маппит `'mdi-close-circle': XCircle` — clearable-иконка теперь рендерится нативным Lucide-SVG. Workaround мёртв → удалить.

**Files:**

- Modify: `app/resources/js/views/ProjectsView.vue` (удаление CSS-блока `:170-196`)

- [ ] **Step 1: Проверить премису (фальсифицировать перед удалением)**

Подтвердить, что `app/resources/js/plugins/vuetify.ts` содержит `'mdi-close-circle': XCircle` в Lucide IconSet-маппинге и `XCircle` импортирован из `lucide-vue-next`. Если маппинга нет — задача **BLOCKED**, эскалировать (workaround снимать нельзя без замены).

- [ ] **Step 2: Удалить CSS-блок workaround'а**

В `app/resources/js/views/ProjectsView.vue` удалить строки `:170-196` целиком — комментарий-заголовок `/* Workaround: MDI-шрифт... */` и 4 CSS-правила: `.projects-view :deep(.v-field__clearable)`, `.projects-view :deep(.v-field__clearable .v-icon)`, `.projects-view :deep(.v-field--dirty .v-field__clearable)::after`, `.projects-view :deep(.v-field--dirty .v-field__clearable:hover)::after`. Соседние блоки (`.projects-grid` выше, `.toolbar-check` ниже) не трогать.

- [ ] **Step 3: Type-check + сборка + существующие тесты**

Run: `cd app && npm run type-check && npm run test:vue -- ProjectsView`
Expected: vue-tsc 0; `ProjectsView.spec.ts` PASS без изменений (правка чисто CSS, JS-поведение не затронуто).

- [ ] **Step 4: Визуальный smoke (Playwright)**

Запустить dev-сервер, открыть `/projects`, ввести текст в поле поиска проектов (`clearable`). Подтвердить: иконка очистки (Lucide `XCircle`) **видима** справа в поле и клик по ней очищает значение. Сделать скриншот. Если иконка не рендерится — premise опровергнута, `git revert` Step 2 и эскалировать.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/views/ProjectsView.vue
git commit -m "chore(cleanup): снять устаревший MDI clearable-workaround (CTO-19 tail) — Sprint 6 I5"
```

---

## Финальная верификация (после всех 5 задач)

- [ ] **Полная регрессия Vitest** — `cd app && npm run test:vue -- --maxWorkers=2` (full-suite без `--maxWorkers=2` OOM'ит в worktree — квирк 98). Ожидаемо: 0 fail; число passed ≥ baseline + новые тесты (T1 ×3, T2 ×2, T4 ×1).
- [ ] **vue-tsc** — `cd app && npm run type-check` → 0 ошибок.
- [ ] **ESLint** — `cd app && npm run lint:vue` → известная pre-existing ошибка `tests/Frontend/ImportView.spec.ts:4` (Sprint 4 долг, вне scope Sprint 6); 0 новых.
- [ ] **Vite build** — `cd app && npm run build` → OK (подтверждает, что DEV-гейт B6 валиден для prod-сборки).
- [ ] **Pest** опционально — Sprint 6 не трогает PHP-файлы (0 backend-изменений), backend-регрессия структурно невозможна; полный прогон Pest — belt, не обязателен.
- [ ] **Финальный holistic code-review** всего диффа Sprint 6.
- [ ] **Pre-push:** gitleaks-full-history + lychee (lefthook не в PATH worktree — прогонять вручную).

## Self-Review (выполнено при написании плана)

- **Spec coverage:** Sprint 6 §3 спека = 8 эпиков. A9/B6/F4/G9/I5 — в плане (5 задач). F5/G8 — внешне блокированы (инфра/Б-1). I2 — отложен с I1 (решение заказчика Sprint 5D). Покрытие полное и обоснованное.
- **Placeholder-скан:** весь production-код приведён дословно (file:line); тест-код — с конкретными ассертами, mount-setup берётся из существующих spec-файлов (они прочитаны на Step 1 каждой задачи).
- **Type consistency:** `POLLING_INTERVAL_MS` / `POLLING_REMINDERS_INTERVAL_MS` — единые имена в T3 (создание + 4 call-site). `data-testid="dev-auth-gap-banner"` — единое имя в T2 (template + 2 теста). `isDevEnv` — единое имя в T2.
