Compare commits
7 Commits
7911dc71d8
...
73ec28267e
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ec28267e | |||
|
|
d3db88708b | ||
|
|
1f74a27cb3 | ||
|
|
c9672183cf | ||
|
|
f8610abc81 | ||
|
|
cabf8d75f4 | ||
|
|
c20e2bf14b |
@ -32,8 +32,10 @@
|
||||
и `storage.ts` (ключи `localStorage` прежней страницы; у проекта AWPCA — прежние, у остальных с суффиксом `@<метка>`; набор ключей — состояние модуля, выставляется при монтировании страницы), отрисовка — `web/src/board/components`.
|
||||
Вид — дизайн-система AURA: токены цвета, шрифта, отступов и теней (светлая и тёмная темы) — `web/src/styles/tokens.css`,
|
||||
единственное место с литералами цвета; базовые компоненты (`Button`, `Notice`, `Dialog`, `Field`, `Panel`, меню и др.) —
|
||||
`web/src/ui/`, остальные стили подключаются к токенам по семантическим именам. Обновление данных из Jira (кнопка в
|
||||
верхней панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только
|
||||
`web/src/ui/`, остальные стили подключаются к токенам по семантическим именам. Оболочка — `web/src/shell/Layout.tsx`: grid из боковой
|
||||
панели `Sidebar.tsx` (закреплена у левого края, сворачивается в полосу значков — ключ `jira-hub-sidebar`; на экранах
|
||||
уже 640 px — строка с «Меню» и выезжающая панель) и страницы; прокрутка — у окна. Обновление данных из Jira (кнопка в
|
||||
боковой панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только
|
||||
источник и события снимка.
|
||||
|
||||
Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
|
||||
65
docs/superpowers/plans/2026-10-22-sidebar.md
Normal file
65
docs/superpowers/plans/2026-10-22-sidebar.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Боковая панель и доска без чередования — план
|
||||
|
||||
> **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:** заменить верхнюю панель приложения закреплённой боковой (со свёрткой и выезжающим вариантом на узком экране), убрать чередование дорожек доски и показать просрочку на карточке доски.
|
||||
|
||||
**Architecture:** `Layout` рисует grid `.shell` из `Sidebar` и `main.page`; прокрутка остаётся у окна. `Sidebar` собирает существующие `ProjectPicker`, `DataStatus`, `ThemeSwitch`, `Menu`; режим (развёрнута / свёрнута / узкий экран) — локальное состояние + `localStorage` + `matchMedia`. Доска: CSS и проп `today` у `Card`.
|
||||
|
||||
**Tech Stack:** React 19 + TS, Vitest + Testing Library, Playwright.
|
||||
|
||||
**Spec:** `openspec/changes/sidebar/` (proposal, design, specs/web-app/spec.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Литералы цвета — только в `web/src/styles/tokens.css` (тест `tokens.test.ts`).
|
||||
- Ключ свёртки `jira-hub-sidebar`, значение `collapsed`; без записи — развёрнута; доступ к `localStorage` в `try/catch`.
|
||||
- Ширины: развёрнутая 240 px, свёрнутая 64 px; узкий экран — `(max-width: 639px)`, строка 56 px, выезжающая панель `min(300px, 85vw)`.
|
||||
- Подписи: «Хаб Jira», «Свернуть панель», «Развернуть панель», «Меню», «Разделы», «Доска связей», «Задачи», «Обновить из Jira», «просрочено».
|
||||
- Модель доски, golden-эталоны, API, ключи `graph-*` не меняются.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Прокрутка доски у окна: восстановление прокрутки, липкая шапка, полоса дат, ящик «Проблемы», `.hscroll` — после смены каркаса (e2e `board.spec` сценарии прокрутки).
|
||||
- Окно карточки и окна диалогов поверх панели (z-index), фокус возвращается после закрытия.
|
||||
- Узкий экран: выезжающая панель не оставляет горизонтальной прокрутки и закрывается при переходе по ссылке.
|
||||
- Свёрнутая панель: «Обновить из Jira» показывает ход пакета (значок крутится, текст события в `title`/`role=status`).
|
||||
- Просрочка: граница — срок сегодня не просрочен; закрытая задача не просрочена.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Доска — без чередования, просрочка на карточке
|
||||
|
||||
**Files:** `web/src/board/board.css`, `web/src/styles/tokens.css`, `web/src/styles/tokens.test.ts`, `web/src/board/components/Card.tsx`, `web/src/board/components/BoardCanvas.tsx` (передача `today`), `web/src/board/BoardPage.tsx`, `web/src/styles/base.css` (`.visually-hidden`, если нет), тест `web/src/board/components/Card.test.tsx`.
|
||||
|
||||
- [ ] Тест `Card`: `today="2026-10-22"`, `due="2026-10-20"`, не закрыта → `.due.late`, `title="просрочено"`, текст «просрочено» в DOM; `closed` → без `late`; `due="2026-10-22"` → без `late`.
|
||||
- [ ] Тест токенов: в `tokens.css` нет `--lane-alt`.
|
||||
- [ ] Реализация (design 9, 10); `cardPropsEqual` сравнивает `today`.
|
||||
- [ ] `npx vitest run` зелёный, коммит.
|
||||
|
||||
### Task 2: Боковая панель (широкий экран)
|
||||
|
||||
**Files:** create `web/src/shell/Sidebar.tsx`, `web/src/shell/sidebarPref.ts`, `web/src/shell/Sidebar.test.tsx`; modify `Layout.tsx`, `DataStatus.tsx` (проп `compact`), `shell.css`, `projects/projects.css`, `ui/Menu.tsx` (`up`), `ui/ui.css`, `ui/icons.tsx`; delete `AppBar.tsx`, `AppBar.test.tsx` (перенести его проверки в `Sidebar.test.tsx`).
|
||||
|
||||
**Interfaces:** `readSidebar(): boolean` (true — свёрнута), `saveSidebar(collapsed: boolean): void`; `Sidebar({ section, project }: { section: Section | null; project: string | null })`; `Section` переезжает в `Sidebar.tsx`.
|
||||
|
||||
- [ ] Тесты: ссылки «Доска связей»/«Задачи» с `aria-current`; «Свернуть панель» → `localStorage['jira-hub-sidebar']==='collapsed'`, ссылки с `aria-label`, повторный рендер — свёрнута; «Сменить токен» очищает токен; в свёрнутой — кнопка «Обновить из Jira» и `role=status`.
|
||||
- [ ] Реализация (design 1–3, 6–8).
|
||||
- [ ] `npx vitest run` и `npm run lint` зелёные, коммит.
|
||||
|
||||
### Task 3: Узкий экран
|
||||
|
||||
**Files:** `Sidebar.tsx`, `Layout.tsx`, `shell.css`, `board/board.css` (`--topbar-h` у `.drawer`), `Sidebar.test.tsx`.
|
||||
|
||||
- [ ] Тесты (matchMedia замокан на узкий): видна кнопка «Меню», панели нет; «Меню» → панель открыта (`aria-expanded=true`), фокус на первой ссылке; Escape → закрыта, фокус на «Меню»; щелчок по разделу → закрыта.
|
||||
- [ ] Реализация (design 4, 5).
|
||||
- [ ] vitest зелёный, коммит.
|
||||
|
||||
### Task 4: e2e, снимки, документация
|
||||
|
||||
**Files:** `web/e2e/*.spec.ts`, `web/e2e/hub.ts`, `web/e2e/screens.spec.ts`, `ARCHITECTURE.md`.
|
||||
|
||||
- [ ] Поправить селекторы `.appbar`/сегментов на панель; новый e2e: после прокрутки доски вниз ссылка «Задачи» в панели видна в окне (`toBeInViewport`); свёртка переживает перезагрузку; на 390 «Меню» → «Задачи» → список, `scrollWidth <= innerWidth`.
|
||||
- [ ] `screens.spec`: добавить снимки `board-collapsed` (1920) и `menu` (390).
|
||||
- [ ] ARCHITECTURE.md: оболочка — боковая панель.
|
||||
- [ ] `make check`, `npm run e2e`, `SCREENS=1 npx playwright test e2e/screens.spec.ts` зелёные; коммит, push.
|
||||
2
openspec/changes/sidebar/.openspec.yaml
Normal file
2
openspec/changes/sidebar/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
91
openspec/changes/sidebar/design.md
Normal file
91
openspec/changes/sidebar/design.md
Normal file
@ -0,0 +1,91 @@
|
||||
## Context
|
||||
|
||||
`main` 7911dc7 (после #66/#67). Оболочка: `web/src/shell/Layout.tsx` рисует `AppBar` (`shell/AppBar.tsx`: название,
|
||||
`ProjectPicker`, `Segmented` разделов со ссылками, `DataStatus`, `ThemeSwitch`, `Menu` «Ещё» со «Сменить токен») и
|
||||
`<main className="page …">`. Прокрутка страниц — у окна (`window.scrollY`): на этом держатся восстановление прокрутки
|
||||
доски (`BoardCanvas`), липкая шапка доски (`.sticky`, `top: 0`, высота — в `--head-h`), липкая полоса дат, ящик
|
||||
«Проблемы» и полоса горизонтальной прокрутки `.hscroll` (`bottom: 0`). Верхняя панель не липкая (решение 15 #65),
|
||||
поэтому уезжает.
|
||||
|
||||
Доска: `.lane:nth-child(even of .lane){background:var(--lane-alt)}`; колонки дат `.dcol` (`.past` — 5% `--p-crit`,
|
||||
`.today` — 7% действия) рисуются в DOM до дорожек, поэтому непрозрачный фон чётной дорожки закрывает их подсветку.
|
||||
Карточка (`board/components/Card.tsx`) показывает «до {дата}» без признака просрочки; признак есть только в карточке
|
||||
задачи (`issues/card.ts` `isOverdue`, бейдж «просрочено») и в списке (красная дата).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** навигация и «Обновить из Jira» всегда на экране; доска шире не теряет (свёртка); просрочка видна на доске.
|
||||
|
||||
**Non-Goals:** см. proposal.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Каркас.** `Layout` рисует `<div class="shell">` с `Sidebar` и `<main class="page …">`. `.shell` — grid
|
||||
`grid-template-columns: var(--sidebar-w) minmax(0, 1fr)`; панель `position: sticky; top: 0; height: 100dvh;
|
||||
overflow-y: auto`. Прокрутка остаётся у окна — восстановление прокрутки, липкие шапка доски, полоса дат, ящик и
|
||||
`.hscroll` работают без изменений. Ширина: развёрнутая 240 px, свёрнутая 64 px (`--sidebar-w`).
|
||||
2. **Состав развёрнутой панели** (сверху вниз): строка «Хаб Jira» + кнопка-иконка «Свернуть панель»
|
||||
(`aria-expanded`); `ProjectPicker` на всю ширину; `<nav aria-label="Разделы">` — ссылки «Доска связей» и «Задачи»
|
||||
со значками, высота 40 px, текущая — фон `--color-bg-selected`, текст действия, `aria-current="page"`; разделитель
|
||||
с автоматическим отступом; блок данных `DataStatus` в колонку (агент, «данные на …», кнопка «Обновить из Jira» на
|
||||
всю ширину, строка события под кнопкой с переносом — абсолютное позиционирование события из R-7 #65 больше не
|
||||
нужно); строка: `ThemeSwitch` слева, `Menu` «Ещё» справа, меню открывается вверх.
|
||||
3. **Свёрнутая панель.** Только значки: кнопка «Развернуть панель», метка текущего проекта мелким текстом (до 6
|
||||
символов, полный текст в `title`; щелчок разворачивает панель), значки разделов (`aria-label` = название раздела,
|
||||
`title`), «Обновить из Jira» — `IconButton` с крутящимся значком при пакете и точкой агента в углу, событие пакета —
|
||||
в `title` и в скрытом `role="status"`, тема, меню. Ключ `localStorage` `jira-hub-sidebar`: `collapsed` — свёрнута,
|
||||
иначе развёрнута; чтение и запись — в `try/catch`.
|
||||
4. **Узкий экран (<640 px).** Панели в потоке нет: сверху липкая строка 56 px — кнопка «Меню» (`IconMenu`,
|
||||
`aria-expanded`), «Хаб Jira», справа `IconButton` «Обновить из Jira». «Меню» открывает ту же развёрнутую панель
|
||||
поверх страницы (фиксирована слева, ширина `min(300px, 85vw)`, подложка `--color-overlay`); закрывается выбором
|
||||
раздела, Escape, щелчком по подложке; фокус при открытии — на первую ссылку раздела, при закрытии — на «Меню».
|
||||
Свёртка на узком экране не действует. Режим определяется `matchMedia("(max-width: 639px)")` с подпиской на смену.
|
||||
5. **Шапка доски** остаётся липкой с `top: 0` (теперь это верх окна); правило `@media (max-width:640px){.sticky{position:static}}`
|
||||
не трогаем, только `top` у `.sticky`, `.dates-bar` и `.drawer` на узком экране сдвигается на высоту строки 56 px
|
||||
через `--topbar-h` (0 на широких).
|
||||
6. **Меню вверх.** `Menu` получает проп `up?: boolean` → класс `dd--up` (`.dd--up > .dd-menu { top: auto; bottom: calc(100% + 4px) }`).
|
||||
7. **Иконки.** Новые в `ui/icons.tsx`: `IconBoard` (граф из трёх узлов), `IconList` (строки), `IconMenu` (три
|
||||
линии), `IconSidebar` (прямоугольник с полосой слева).
|
||||
8. **`AppBar` удаляется**, `Segmented` больше не нужен оболочке (остаётся в `ui` для списка задач). `.appbar …` в
|
||||
`shell.css` и `projects.css` заменяются стилями `.sidebar`.
|
||||
9. **Дорожки без чередования.** Правило `.lane:nth-child(even of .lane)` и токен `--lane-alt` удаляются в обеих темах;
|
||||
развёрнутые дорожки прозрачны (фон — поверхность `.wrap`), разделитель — верхняя граница; свёрнутая дорожка
|
||||
остаётся на `--color-bg-track`.
|
||||
10. **Просрочка на карточке доски.** `Card` получает проп `today: string` (ISO, из `BoardPage`, где он уже есть для
|
||||
`computeVisible`) и считает `late = !closed && !!n.due && n.due < today`; у `.due` класс `late` — цвет
|
||||
`--color-status-error`, `title="просрочено"`, скрытый текст «, просрочено» (`.visually-hidden`). Карточка в слое
|
||||
измерения тоже получает `today`, размер не меняется (скрытый текст вне потока). Сравнение в `cardPropsEqual`
|
||||
дополняется `today`. Модель доски и эталоны не меняются.
|
||||
11. **Тесты.** Vitest: `Sidebar` (разделы и `aria-current`, свёртка и ключ `jira-hub-sidebar`, «Сменить токен»,
|
||||
узкий режим: «Меню» открывает и Escape закрывает, выбор раздела закрывает), `Card` (просрочка: незакрытая с
|
||||
прошедшим сроком — `late`, закрытая и будущая — нет), тест токенов (нет `--lane-alt`). Playwright: существующие
|
||||
сценарии с новыми селекторами; новый — панель видна после прокрутки доски вниз и свёртка переживает перезагрузку;
|
||||
`screens.spec` — снимки свёрнутой панели и открытого меню на 390.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Панель 240 px сужает доску на ноутбуке] → свёртка в 64 px, выбор запоминается.
|
||||
- [Две копии меню на узком экране (строка и панель)] → нет: на узком экране панель одна, она же выезжает; в строке
|
||||
только «Меню» и «Обновить из Jira».
|
||||
- [e2e и юнит-тесты, завязанные на `.appbar`] → правятся в той же задаче, что и разметка.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Владелец 22.10.2026: «в графе связей идёт чередование строк по цвету. оно мешает. его или сделать менее заметным или
|
||||
убрать вообще, тк не видно просроченные задачи, и закрепить хэдер, а лучше сделать его сайдбаром». Ранее (#65)
|
||||
отдал UX на усмотрение агента. Всё ниже — решения агента.
|
||||
|
||||
| # | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 22.10.2026 | Боковая панель, а не липкая верхняя (решения 1–2) | владелец предпочёл «лучше сайдбар»; вертикальный список разделов растёт без переноса строк | средняя: вернуть верхнюю панель — новая вёрстка оболочки |
|
||||
| 2 | 22.10.2026 | Чередование убрано совсем, не ослаблено (решение 9) | любой непрозрачный фон дорожки снова закроет колонки дат; разделителя-линии достаточно | низкая: вернуть правило и токен |
|
||||
| 3 | 22.10.2026 | Просрочка на карточке доски цветом ошибки + «просрочено» в подсказке (решение 10) | «не видно просроченные» — вне режима «по датам» просрочку на доске не было видно вовсе | низкая: класс и проп |
|
||||
| 4 | 22.10.2026 | Свёртка панели в полосу 64 px с запоминанием (решение 3) | доске нужна ширина; раз в спринт граф — тогда удобно свернуть | низкая |
|
||||
| 5 | 22.10.2026 | На узком экране — строка с «Меню» и выезжающая панель (решение 4) | закреплённая панель 4–5 строк (M-12 #65) съела бы экран | низкая: узкий экран — только «без поломок» |
|
||||
| 6 | 22.10.2026 | Развёрнута по умолчанию, ширина 240 px | на 1920 место есть; название проекта видно целиком | низкая |
|
||||
| 7 | 22.10.2026 | R-1: панель без `overflow-y:auto` (вопреки решению 1) | прокрутка панели обрезала бы меню «Ещё» свёрнутой панели, выходящее за 64 px | низкая: на окне ниже ~470 px низ панели обрезан (M-5) |
|
||||
| 8 | 22.10.2026 | R-2: в выезжающей панели «Закрыть меню» вместо «Свернуть панель» | свёртка внутри выезжающей панели бессмысленна (решение 4) | нет |
|
||||
| 9 | 22.10.2026 | R-3: e2e M-3 переходит по разделам обычным щелчком, не `dispatchEvent` | панель закреплена, щелчок больше не прокручивает доску; тест заодно проверяет закрепление | нет |
|
||||
| 10 | 22.10.2026 | R-4 (C-1): `.shell>.page{width:100%}` и `.page` — контейнер (`container: page/inline-size`); пороги раскладки страниц (фильтры списка, прокрутка таблицы, колонка ящика «Проблемы») — запросами к контейнеру с порогами «прежний порог окна минус поля страницы 48 px» | рядом панель 240/64 px: ширина страницы больше не равна ширине окна, и пороги по окну давали таблицу шире страницы | низкая: пороги — числа в CSS |
|
||||
| 11 | 22.10.2026 | R-5 (I-1): на узком экране событие пакета — строкой под закреплённой строкой (`aria-hidden`, озвучивает `role=status` кнопки) | на телефоне подсказок нет, результат «Обновить из Jira» был не виден | низкая: строка занимает 1–2 строки по высоте, пока есть событие |
|
||||
|
||||
24
openspec/changes/sidebar/proposal.md
Normal file
24
openspec/changes/sidebar/proposal.md
Normal file
@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
После редизайна (#65) владелец 22.10.2026 оставил два замечания (jira-hub#69):
|
||||
|
||||
- на доске связей дорожки чередуются по цвету фона; это мешает и прячет просроченные задачи — подсветка прошедших
|
||||
дат в режиме «по датам» лежит под фоном каждой второй дорожки, а на самой карточке просрочка ничем не отмечена;
|
||||
- верхняя панель приложения уезжает при прокрутке — «закрепить хэдер, а лучше сделать его сайдбаром».
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Боковая панель вместо верхней:** название, проект, разделы вертикальным списком со значками, внизу — блок данных
|
||||
Jira с «Обновить из Jira», тема и меню «Ещё». Панель закреплена у левого края на всю высоту окна и не прокручивается
|
||||
со страницей; сворачивается в узкую полосу со значками (выбор хранится в `localStorage`). На экранах уже 640 px —
|
||||
закреплённая строка с кнопкой «Меню», которая открывает панель поверх страницы.
|
||||
- **Доска без чередования дорожек:** фон развёрнутых дорожек один, дорожки разделяются линией; подсветка колонок «по
|
||||
датам» видна на всех дорожках.
|
||||
- **Просрочка на карточке доски:** у незакрытой задачи с прошедшим сроком дата «до …» — цветом ошибки, с пометкой
|
||||
«просрочено» для подсказки и экранного чтения.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Изменения состава действий оболочки, логики обновления из Jira, адресов и хранилища доски.
|
||||
- Новые разделы в навигации.
|
||||
- Изменения API хаба, MCP, агента; модели доски и golden-эталонов.
|
||||
254
openspec/changes/sidebar/specs/web-app/spec.md
Normal file
254
openspec/changes/sidebar/specs/web-app/spec.md
Normal file
@ -0,0 +1,254 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Оболочка и адреса
|
||||
Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список
|
||||
задач) и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь —
|
||||
на страницу «не найдено» со ссылками на разделы. Боковая панель SHALL быть закреплена у левого края окна на всю его
|
||||
высоту и MUST NOT прокручиваться вместе со страницей; страница занимает остальную ширину. Сверху вниз панель SHALL
|
||||
содержать: название «Хаб Jira» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий
|
||||
отмечен, в конце «+ Новый проект…»; требование «Проект в приложении»); разделы «Доска связей» и «Задачи» — вертикальным
|
||||
списком ссылок со значками (текущий отмечен `aria-current="page"`); внизу — блок данных Jira с действием «Обновить из
|
||||
Jira» (требование «Обновление из Jira»), переключатель темы (требование «Тема») и меню с действием «Сменить токен».
|
||||
Свёрнутая панель SHALL быть узкой полосой с теми же действиями значками: «Развернуть панель», метка текущего проекта,
|
||||
разделы, «Обновить из Jira» с точкой состояния агента, тема, меню; названия — в подсказках и `aria-label`. Свёртка
|
||||
SHALL храниться в `localStorage` под ключом `jira-hub-sidebar` (`collapsed`; без записи — развёрнута). На экранах уже
|
||||
640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», названием и кнопкой «Обновить из
|
||||
Jira»; «Меню» открывает развёрнутую панель поверх страницы, панель закрывается выбором раздела, клавишей Escape или
|
||||
щелчком мимо неё. Горизонтальной прокрутки страницы на любой ширине нет.
|
||||
Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не пустым экраном.
|
||||
|
||||
#### Scenario: Переход между разделами
|
||||
- **WHEN** на доске в боковой панели выбран раздел «Задачи»
|
||||
- **THEN** адрес — `/app/issues` с текущим проектом, показан список задач; «назад» возвращает на доску
|
||||
|
||||
#### Scenario: Сменить токен
|
||||
- **WHEN** в меню боковой панели выбрано «Сменить токен»
|
||||
- **THEN** токен очищен, показан экран входа
|
||||
|
||||
#### Scenario: Панель на месте при прокрутке
|
||||
- **WHEN** доска прокручена до самого низа
|
||||
- **THEN** боковая панель видна целиком на прежнем месте: разделы и «Обновить из Jira» доступны без прокрутки вверх
|
||||
|
||||
#### Scenario: Свёрнутая панель
|
||||
- **WHEN** нажата «Свернуть панель», страница перезагружена
|
||||
- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; «Развернуть панель»
|
||||
возвращает полную панель
|
||||
|
||||
#### Scenario: Узкий экран
|
||||
- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи»
|
||||
- **THEN** панель открылась поверх страницы, после выбора закрыта, показан список задач; горизонтальной прокрутки нет
|
||||
|
||||
### Requirement: Доска связей
|
||||
Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
|
||||
кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
|
||||
фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
|
||||
карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
|
||||
фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «соседи по связям», «только
|
||||
передачи», «скрыть без связей», «критический путь», «по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
|
||||
связей; экспорт и импорт JSON; «Сбросить раскладку». Раскладка, дорожки и фильтры SHALL храниться в
|
||||
`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
|
||||
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
|
||||
на одних и тех же данных и фильтрах.
|
||||
|
||||
Шапка доски (закреплена при прокрутке) SHALL содержать заголовок «Доска связей», счётчики (задачи из всех, соседи по
|
||||
связям, связи из всех, скрытые без связей), пометку источника, если на доске снимок из файла (с действием вернуться к
|
||||
данным хаба), событие доски, кнопку «Проблемы» со счётчиком и меню с действиями «Перечитать», «Загрузить файл…»,
|
||||
«Экспорт JSON», «Таблица связей», «Привязка к сетке» и «Сбросить раскладку» (с подтверждением). Под шапкой — панель
|
||||
управления: мультиселекты «Спринты» и «Исполнители», переключатели группы «Показать» («Соседи по связям», «Только
|
||||
передачи», «Скрыть без связей») и группы «Режим» («Критический путь», «По датам»), каждый с `aria-pressed` и прежней
|
||||
подсказкой. Под панелью — легенда, которая сворачивается кнопкой «Легенда»; свёрнутая показывает число скрытых видов
|
||||
работ и типов связей; состояние свёртки хранится в `localStorage` под ключом `jira-hub-legend`. «Обновить из Jira»,
|
||||
тема и «Сменить токен» — в боковой панели приложения, на доске их нет.
|
||||
|
||||
У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
|
||||
открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто
|
||||
и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
|
||||
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
|
||||
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
|
||||
Карточка задачи, статус Jira которой входит в `closed_statuses`, SHALL быть приглушена (полупрозрачна), под
|
||||
указателем и с фокусом клавиатуры внутри карточки — в полную яркость; стрелка связи, у которой хотя бы один конец — такая задача, SHALL быть приглушена так же.
|
||||
Приглушение — только вид: модель, видимая выборка, критический путь, проблемы, раскладка и экспорт JSON от него не
|
||||
зависят; переключателя нет; правило одно для данных хаба, CSV и JSON.
|
||||
При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю
|
||||
прокрутку; после перезагрузки страницы доска открывается сверху.
|
||||
|
||||
Дорожки SHALL разделяться линией; фон развёрнутых дорожек один, без чередования, чтобы подсветка колонок «по датам»
|
||||
(прошедшие даты, сегодня) была видна на всех дорожках; свёрнутая дорожка выделяется фоном. У карточки задачи, статус
|
||||
Jira которой не входит в `closed_statuses`, с плановой датой окончания раньше сегодняшней дата «до …» SHALL быть цветом
|
||||
ошибки, с подсказкой и текстом для экранного чтения «просрочено»; это только вид, модель и раскладка от него не зависят.
|
||||
|
||||
#### Scenario: Раскладка переживает переход
|
||||
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
|
||||
- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
|
||||
|
||||
#### Scenario: Совпадение со старой доской
|
||||
- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
|
||||
и проблемы старой страницей и приложением
|
||||
- **THEN** результаты совпадают
|
||||
|
||||
#### Scenario: Открыть карточку
|
||||
- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
|
||||
- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска
|
||||
|
||||
#### Scenario: Возврат с карточки
|
||||
- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто
|
||||
- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board`
|
||||
|
||||
#### Scenario: Возврат из другого раздела
|
||||
- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска связей»
|
||||
- **THEN** доска показана с той же прокруткой
|
||||
|
||||
#### Scenario: Закрытые на доске приглушены
|
||||
- **WHEN** на доске AURORA-1620 «В работе», AURORA-1621 «Готово», AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621
|
||||
- **THEN** карточки AURORA-1621 и AURORA-1622 и стрелка AURORA-1620 → AURORA-1621 приглушены, AURORA-1620 — нет;
|
||||
под указателем AURORA-1621 — в полную яркость; критический путь и ящик «Проблемы» те же, что без приглушения
|
||||
|
||||
#### Scenario: Свёрнутая легенда
|
||||
- **WHEN** в легенде скрыт вид работы «тестирование», легенда свёрнута, страница перезагружена
|
||||
- **THEN** легенда свёрнута, рядом с кнопкой «Легенда» — «скрыто: 1»; карточек вида «тестирование» на доске нет
|
||||
|
||||
#### Scenario: Просрочка на доске
|
||||
- **WHEN** сегодня 22.10, у AURORA-1620 статус «В работе» и срок 20.10, у AURORA-1621 «Готово» и срок 20.10, включено
|
||||
«По датам»
|
||||
- **THEN** дата AURORA-1620 — цветом ошибки с пометкой «просрочено», у AURORA-1621 — обычная; подсветка колонки 20.10
|
||||
видна на всех дорожках, дорожки одного фона
|
||||
|
||||
### Requirement: Источник данных доски
|
||||
Доска SHALL хранить явный источник данных — хаб или файл. Пока показан снимок из файла (CSV или JSON-снимок), ответы
|
||||
хаба SHALL его не заменять; «Перечитать» SHALL возвращать источник «хаб» и загружать `GET /board` заново без обращения к
|
||||
Jira. Импорт JSON в режиме хаба SHALL применять раскладку, дорожки и фильтры и перечитывать хаб. Шапка доски SHALL
|
||||
показывать источник данных (пометка «из файла» с именем файла; данные хаба пометки не имеют) и одно текущее событие
|
||||
доски (загрузка; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; ошибка разбора
|
||||
файла — «файл не разобран: <причина>»; раскладка и фильтры из JSON применены; ошибка); новое событие заменяет
|
||||
предыдущее целиком. События пакета обновления из Jira показывает боковая панель приложения (требование «Обновление из
|
||||
Jira»), не шапка доски.
|
||||
|
||||
#### Scenario: Поздний ответ хаба не затирает файл
|
||||
- **WHEN** запрос `GET /board` в пути, и в это время загружен CSV
|
||||
- **THEN** после ответа хаба на доске по-прежнему данные CSV с пометкой «из файла»
|
||||
|
||||
#### Scenario: Пакет завершился при снимке из файла
|
||||
- **WHEN** на доске снимок из CSV и завершился пакет обновления из Jira
|
||||
- **THEN** на доске по-прежнему данные CSV с пометкой «из файла»; после «Перечитать» — данные хаба
|
||||
|
||||
#### Scenario: Хаб недоступен при снимке из файла
|
||||
- **WHEN** хаб не отвечает и загружен JSON-снимок доски
|
||||
- **THEN** шапка показывает «хаб не отвечает — на доске снимок из файла», а не «показаны прежние данные»
|
||||
|
||||
#### Scenario: Нет второго полного прогона
|
||||
- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, доска перерисована (фильтр, «Перечитать»)
|
||||
- **THEN** кнопка «Обновить из Jira» в боковой панели остаётся недоступной, в хабе один пакет, его опрашивает один опрос
|
||||
|
||||
#### Scenario: Пакет пропал
|
||||
- **WHEN** опрос пакета получил 404, на доске данные хаба
|
||||
- **THEN** доска показывает прежние данные без своего события об ошибке; сообщение о пропавшем пакете — в боковой панели
|
||||
|
||||
#### Scenario: Пакет после «Перечитать»
|
||||
- **WHEN** пакет `viewer` поставлен с другой вкладки и не завершён, на доске нажато «Перечитать»
|
||||
- **THEN** боковая панель показывает «пакет #N выполняется», по завершении доска перечитывает данные
|
||||
|
||||
#### Scenario: «Перечитать» во время пакета
|
||||
- **WHEN** пакет `viewer` выполняется и на доске нажато «Перечитать»
|
||||
- **THEN** боковая панель по-прежнему показывает «пакет #N выполняется», шапка доски — без события пакета
|
||||
|
||||
#### Scenario: Чужой пакет не подхватывается
|
||||
- **WHEN** незавершённый пакет автора `viewer` состоит из команд `ensure_fields`, открыта доска
|
||||
- **THEN** доска и боковая панель его не опрашивают, кнопка «Обновить из Jira» доступна
|
||||
|
||||
### Requirement: Проект в приложении
|
||||
Доска и список задач SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром `project` в
|
||||
адресе `/app/board` и `/app/issues`; без параметра — последний выбранный в этом браузере (`localStorage`), если он есть
|
||||
в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории.
|
||||
Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и выпадающий список
|
||||
проектов вместо данных. Проектов нет — доска и список SHALL показывать приглашение создать проект («+ Новый проект…»).
|
||||
Переходы «Доска связей» / «Задачи» SHALL сохранять текущий проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит.
|
||||
|
||||
Доска SHALL загружать `GET /board?project=<текущий>`; «Обновить из Jira» — `POST /refresh {full: true}`, обновляет все
|
||||
проекты. Раскладка, дорожки и фильтры доски в `localStorage` SHALL храниться отдельно для каждого проекта;
|
||||
у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов, сохраняется). Фильтр спринтов SHALL
|
||||
показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`) есть спринт; иначе — «колонки
|
||||
«Спринт» нет — показаны все задачи».
|
||||
|
||||
Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка,
|
||||
название, номер доски Jira (необязательно; подсказка «scrum или kanban определится сам»). «Создать» — `POST /projects`
|
||||
с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с кнопкой, окно остаётся открытым; успех — окно
|
||||
закрывается, текущим становится новый проект, боковая панель показывает ход пакета обновления из ответа (`batch_id`).
|
||||
|
||||
#### Scenario: Первый вход после выкатки
|
||||
- **WHEN** открыт `/app/board` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK`
|
||||
- **THEN** адрес — `/app/board?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек
|
||||
|
||||
#### Scenario: Переключение проекта
|
||||
- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи»
|
||||
- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» — `/app/issues?project=STOCKS_DRRK`
|
||||
|
||||
#### Scenario: Неизвестный проект в адресе
|
||||
- **WHEN** открыт `/app/issues?project=NOPE`
|
||||
- **THEN** «проект NOPE не найден» и список проектов; запроса `GET /issues` нет
|
||||
|
||||
#### Scenario: Создание проекта из формы
|
||||
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска пусто; нажато «Создать»
|
||||
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`;
|
||||
боковая панель показывает «пакет #N выполняется»
|
||||
|
||||
#### Scenario: Метка занята
|
||||
- **WHEN** в форме введена метка `awpca`, хаб ответил 409
|
||||
- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба
|
||||
|
||||
#### Scenario: Канбан-проект
|
||||
- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть)
|
||||
- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи»
|
||||
|
||||
### Requirement: Обновление из Jira
|
||||
Действие «Обновить из Jira» SHALL быть в боковой панели приложения и доступно с любой страницы. Оно SHALL проверять
|
||||
агента (`GET /agent`; офлайн — предупреждение, пакет всё равно ставится), ставить `POST /refresh {full: true, author:
|
||||
"viewer"}` и опрашивать `GET /batches/{id}` каждые 3 с одним опросом до терминального статуса пакета. Кнопка SHALL быть
|
||||
недоступна (с признаком загрузки), пока запрос `/refresh` в пути или пакет выполняется. Незавершённый пакет автора
|
||||
`viewer`, первая команда которого — `refresh`, SHALL подхватываться при входе в приложение и после каждой успешной
|
||||
загрузки снимка доски, кроме перечитывания снимка после завершения собственного пакета; пакеты `viewer` с другими
|
||||
командами не подхватываются. Опрос SHALL прекращаться при ответе 4xx (404 — «пакета нет в хабе») и после 5 ошибок
|
||||
«хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. По завершении пакета приложение SHALL перечитывать
|
||||
снимки досок, списки задач, открытую карточку и список проектов; доска, показывающая снимок из файла, его не заменяет.
|
||||
Пакет, поставленный формой «Новый проект», SHALL опрашиваться так же сразу после создания.
|
||||
|
||||
Боковая панель SHALL показывать блок данных Jira: состояние агента («агент на связи» / «агент офлайн», по `GET /agent`
|
||||
при входе и раз в 60 с), время последнего полного обновления («данные на ДД.ММ ЧЧ:ММ» по `last_sync` снимка доски
|
||||
текущего проекта, «полного обновления не было», если его нет) и одно текущее событие пакета: в очереди / выполняется (с
|
||||
оставшимся временем, с пометкой «агент офлайн» и «хаб не отвечает, повтор»), выполнен, не выполнен с текстом ошибки,
|
||||
отменён, опрос остановлен с причиной, хаб не ответил на постановку; новое событие заменяет предыдущее, «выполнен»
|
||||
исчезает через 10 с.
|
||||
|
||||
#### Scenario: Обновление со списка задач
|
||||
- **WHEN** открыт `/app/issues`, нажато «Обновить из Jira», пакет выполнен
|
||||
- **THEN** в хабе один пакет `refresh` автора `viewer`; список задач перезапрошен; боковая панель показывала «пакет #N
|
||||
выполняется», затем «пакет #N выполнен»
|
||||
|
||||
#### Scenario: Один прогон на всё приложение
|
||||
- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, выполнен переход между разделами и «Перечитать» на доске
|
||||
- **THEN** кнопка остаётся недоступной, в хабе один пакет, его опрашивает один опрос
|
||||
|
||||
#### Scenario: Опрос получил 404
|
||||
- **WHEN** опрос пакета получил 404
|
||||
- **THEN** опрос остановлен, боковая панель показывает «пакета нет в хабе», кнопка «Обновить из Jira» снова доступна
|
||||
|
||||
#### Scenario: Пакет с другой вкладки
|
||||
- **WHEN** пакет `viewer` с командой `refresh` поставлен с другой вкладки и не завершён, приложение открыто
|
||||
- **THEN** боковая панель показывает «пакет #N выполняется» и по завершении данные перечитываются
|
||||
|
||||
#### Scenario: Пакет с другими командами
|
||||
- **WHEN** незавершённый пакет автора `viewer` состоит из команд `ensure_fields`
|
||||
- **THEN** приложение его не опрашивает, кнопка «Обновить из Jira» доступна
|
||||
|
||||
#### Scenario: Агент офлайн
|
||||
- **WHEN** `GET /agent` отвечает `online: false` и нажато «Обновить из Jira»
|
||||
- **THEN** пакет поставлен, событие — «агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #N в очереди…»
|
||||
|
||||
### Requirement: Тема
|
||||
Переключатель темы SHALL быть в боковой панели приложения: два положения — светлая и тёмная. Выбор SHALL храниться в
|
||||
`localStorage` под ключом `graph-theme` (`light` / `dark`), как у прежней доски; без сохранённого выбора или со
|
||||
значением `""` тема SHALL следовать настройке системы, и переключатель показывает её. Тема применяется до первой
|
||||
отрисовки, без мигания.
|
||||
|
||||
#### Scenario: Выбор темы сохраняется
|
||||
- **WHEN** в системе светлая тема, переключатель переведён в «тёмная», страница перезагружена
|
||||
- **THEN** приложение в тёмной теме, в `localStorage` `graph-theme` = `dark`
|
||||
@ -82,10 +82,10 @@ test("M-3: прокрутка и данные доски после окна к
|
||||
await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
expect(boardLoads).toBe(0); // доска не перезагружалась
|
||||
// ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
|
||||
// боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/);
|
||||
await page.getByRole("link", { name: "Доска связей", exact: true }).dispatchEvent("click");
|
||||
await page.getByRole("link", { name: "Доска связей", exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,8 @@ export async function login(page: Page, path = "/app/board") {
|
||||
await page.goto(path);
|
||||
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
await expect(page.getByRole("link", { name: "Доска связей" })).toBeVisible();
|
||||
// оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69)
|
||||
await expect(page.getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible();
|
||||
}
|
||||
|
||||
async function call(path: string, body: unknown) {
|
||||
|
||||
60
web/e2e/layout.spec.ts
Normal file
60
web/e2e/layout.spec.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { login } from "./hub.ts";
|
||||
|
||||
// оболочка с боковой панелью (#69)
|
||||
test("панель на месте после прокрутки доски до низа", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 600 });
|
||||
await login(page);
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
expect(y).toBeGreaterThan(100);
|
||||
const side = page.getByRole("complementary", { name: "Панель приложения" });
|
||||
await expect(side.getByRole("link", { name: "Задачи" })).toBeInViewport();
|
||||
await expect(side.getByRole("button", { name: "Обновить из Jira" })).toBeInViewport();
|
||||
expect((await side.boundingBox())!.y).toBe(0);
|
||||
});
|
||||
|
||||
test("свёрнутая панель переживает перезагрузку, доска шире", async ({ page }) => {
|
||||
await login(page);
|
||||
const wrap = page.locator(".wrap");
|
||||
await expect(wrap).toBeVisible();
|
||||
const before = (await wrap.boundingBox())!.width;
|
||||
await page.getByRole("button", { name: "Свернуть панель" }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Развернуть панель", exact: true })).toBeVisible();
|
||||
await expect.poll(async () => (await wrap.boundingBox())!.width).toBeGreaterThan(before + 150);
|
||||
await page.getByRole("link", { name: "Задачи" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/);
|
||||
await page.getByRole("button", { name: "Развернуть панель", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Свернуть панель" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("390 px: «Меню» открывает панель, «Задачи» закрывает её и открывает список; без горизонтальной прокрутки", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
await login(page);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.getByRole("button", { name: "Меню" }).click();
|
||||
const side = page.getByRole("complementary", { name: "Панель приложения" });
|
||||
await expect(side).toBeVisible();
|
||||
await side.getByRole("link", { name: "Задачи" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/);
|
||||
await expect(side).toHaveCount(0);
|
||||
await expect(page.getByTestId("page-issues")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test("C-1: на средних ширинах страница не шире окна — «Задачи» на 1280, доска на 1000 прокручивается сама", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page, "/app/issues");
|
||||
await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await page.getByRole("link", { name: "Доска связей" }).click();
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "По датам" }).click();
|
||||
await expect(page.locator(".dcol").first()).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1000);
|
||||
await expect(page.getByRole("button", { name: /Проблемы/ })).toBeInViewport();
|
||||
const wrap = page.locator(".wrap");
|
||||
expect(await wrap.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true); // доска шире своей рамки — прокрутка внутри
|
||||
});
|
||||
@ -80,12 +80,31 @@ for (const theme of ["light", "dark"] as const) {
|
||||
|
||||
test("new-project", async ({ page }) => {
|
||||
await login(page);
|
||||
if (w < 640) await page.getByRole("button", { name: "Меню" }).click(); // на узком экране проект — в выезжающей панели
|
||||
await page.getByLabel("Проект", { exact: true }).selectOption({ label: "+ Новый проект…" });
|
||||
await expect(page.getByRole("dialog", { name: "Новый проект" })).toBeVisible();
|
||||
await settle(page);
|
||||
await shot(page, "new-project");
|
||||
});
|
||||
|
||||
test("board-collapsed", async ({ page }) => {
|
||||
test.skip(w < 640, "свёртка — только на широком экране");
|
||||
await page.addInitScript(() => { localStorage.setItem("jira-hub-sidebar", "collapsed"); });
|
||||
await login(page);
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
await settle(page);
|
||||
await shot(page, "board-collapsed");
|
||||
});
|
||||
|
||||
test("menu", async ({ page }) => {
|
||||
test.skip(w >= 640, "выезжающая панель — только на узком экране");
|
||||
await login(page);
|
||||
await page.getByRole("button", { name: "Меню" }).click();
|
||||
await expect(page.getByRole("complementary", { name: "Панель приложения" })).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({ path: `${OUT}/${theme}-${w}-menu.png` });
|
||||
});
|
||||
|
||||
test("refresh", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.getByRole("button", { name: "Обновить из Jira" }).click();
|
||||
|
||||
@ -50,7 +50,6 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.empty{padding:40px 16px;color:var(--color-text-secondary);text-align:center}
|
||||
#hubState{white-space:normal;min-width:0}
|
||||
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--color-border-default)}
|
||||
.lane:nth-child(even of .lane){background:var(--lane-alt)}
|
||||
.lane.folded{background:var(--color-bg-track);border-bottom:1px solid var(--color-border-strong);border-top-color:var(--color-border-strong)}
|
||||
.lane.target{box-shadow:inset 0 3px 0 var(--color-action-primary)}
|
||||
.lane.target-after{box-shadow:inset 0 -3px 0 var(--color-action-primary)}
|
||||
@ -134,12 +133,13 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
/* статус — вид Badge (neutral) */
|
||||
.status{font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;padding:1px 8px;border:1px solid var(--color-border-default);border-radius:var(--radius-round);background:var(--color-bg-track);color:var(--color-text-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto}
|
||||
.due.late{color:var(--color-status-error)}
|
||||
.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--color-action-primary);margin-right:5px;vertical-align:1px}
|
||||
.who.none{color:var(--color-text-secondary);font-style:italic}
|
||||
.who.none::before{background:transparent;border:1px solid var(--color-text-secondary)}
|
||||
|
||||
/* 7. Панель проблем справа от доски, липнет под шапкой; прокручивается содержимое внутри рамки Panel */
|
||||
.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)}
|
||||
.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--topbar-h,0px) + var(--head-h,0px) + 8px);max-height:calc(100vh - var(--topbar-h,0px) - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)}
|
||||
.drawer>.ui-panel{display:flex;flex-direction:column;gap:var(--space-3);min-height:0;min-width:0;flex:1 1 auto;padding:var(--space-4) 0 var(--space-4) var(--space-4)}
|
||||
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2);padding-right:var(--space-4)}
|
||||
.drawer-body{overflow-y:auto;min-height:0;padding-right:var(--space-4)}
|
||||
@ -167,7 +167,7 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap;color:var(--
|
||||
/* 9. Движение и узкие экраны */
|
||||
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
|
||||
@media (prefers-reduced-motion:reduce){.card .grip,.fold svg{transition:none}}
|
||||
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
|
||||
@container page (max-width:852px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
|
||||
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}}
|
||||
@media (max-width:640px){.board-controls__sep,.legend .sep{display:none}}
|
||||
|
||||
|
||||
@ -190,7 +190,7 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
))}
|
||||
{Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
|
||||
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
|
||||
closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||||
closed={closedKeys.has(k)} today={today} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||||
))}
|
||||
<Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
|
||||
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
|
||||
|
||||
@ -14,6 +14,7 @@ export interface CardProps {
|
||||
multiSprint: boolean;
|
||||
sprintSel: Set<string>;
|
||||
closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена
|
||||
today?: string; // ISO-дата сегодня: незакрытая задача со сроком раньше — просрочена (#69)
|
||||
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
|
||||
onAutoSize?: (key: string) => void;
|
||||
}
|
||||
@ -21,13 +22,14 @@ export interface CardProps {
|
||||
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
|
||||
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
|
||||
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed &&
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed && a.today === b.today &&
|
||||
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
|
||||
}
|
||||
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) {
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, today, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (closed ? " closed" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
|
||||
const late = !closed && !!n.due && !!today && n.due < today;
|
||||
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
|
||||
return (
|
||||
<div className={cls} title={n.title} style={style} data-key={n.key}
|
||||
@ -48,7 +50,8 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
|
||||
{(n.status || n.due) && (
|
||||
<div className="state">
|
||||
{n.status && <span className="status" title={n.status}>{n.status}</span>}
|
||||
{n.due && <span className="due">до {fmtDate(n.due)}</span>}
|
||||
{n.due && <span className={"due" + (late ? " late" : "")} title={late ? "просрочено" : undefined}>
|
||||
до {fmtDate(n.due)}{late && <span className="visually-hidden">, просрочено</span>}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!n.stub && <div className={"who" + (unassigned(n.who) ? " none" : "")} title={n.who}>{shortName(n.who)}</div>}
|
||||
|
||||
30
web/src/board/components/CardLate.test.tsx
Normal file
30
web/src/board/components/CardLate.test.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Card, cardPropsEqual, type CardProps } from "./Card.tsx";
|
||||
|
||||
const node = { key: "A-1", title: "Т", kind: "разработка", prio: "Средний", status: "В работе", due: "2026-10-20", who: "Иванов И. И." } as CardProps["node"];
|
||||
const show = (p: Partial<CardProps>) => render(
|
||||
<MemoryRouter><Card node={node} box={{ x: 0, y: 0, w: 240, h: 120 }} multiSprint={false} sprintSel={new Set()} today="2026-10-22" {...p} /></MemoryRouter>,
|
||||
).container.querySelector(".due")!;
|
||||
|
||||
describe("просрочка на карточке доски (#69, design 10)", () => {
|
||||
it("незакрытая задача с прошедшим сроком — дата цветом ошибки, «просрочено» в подсказке и тексте", () => {
|
||||
const due = show({});
|
||||
expect(due.classList.contains("late")).toBe(true);
|
||||
expect(due.getAttribute("title")).toBe("просрочено");
|
||||
expect(due.textContent).toContain("просрочено");
|
||||
});
|
||||
it("закрытая задача не просрочена", () => {
|
||||
expect(show({ closed: true }).classList.contains("late")).toBe(false);
|
||||
});
|
||||
it("срок сегодня — ещё не просрочено", () => {
|
||||
const due = show({ node: { ...node, due: "2026-10-22" } });
|
||||
expect(due.classList.contains("late")).toBe(false);
|
||||
expect(due.textContent).not.toContain("просрочено");
|
||||
});
|
||||
it("смена дня перерисовывает карточку", () => {
|
||||
const base: CardProps = { node, multiSprint: false, sprintSel: new Set(), today: "2026-10-20" };
|
||||
expect(cardPropsEqual(base, { ...base, today: "2026-10-21" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -7,10 +7,10 @@
|
||||
.issue-filters__row .ui-btn[aria-pressed="true"],.issue-filters__row .ui-btn[aria-expanded="true"]{border-color:var(--color-border-selected);color:var(--color-action-primary)}
|
||||
.issue-filters__chips{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
|
||||
.issue-filters__grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-3) var(--space-4)}
|
||||
@media (max-width:899.98px){.issue-filters__grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media (max-width:639.98px){.issue-filters__grid{grid-template-columns:minmax(0,1fr)}.issue-filters__search{max-width:none}}
|
||||
/* прокрутка внутри панели — только на узких экранах: overflow на предке отключает липкую шапку страницы */
|
||||
@media (max-width:1099.98px){.issue-table-wrap{overflow-x:auto}}
|
||||
@container page (max-width:851.98px){.issue-filters__grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@container page (max-width:607.98px){.issue-filters__grid{grid-template-columns:minmax(0,1fr)}.issue-filters__search{max-width:none}}
|
||||
/* прокрутка внутри панели — только при узкой странице: overflow на предке отключает липкую шапку страницы; пороги контейнера = прежние пороги окна минус поля страницы */
|
||||
@container page (max-width:1051.98px){.issue-table-wrap{overflow-x:auto}}
|
||||
.issue-table th:first-child{border-top-left-radius:calc(var(--radius-xl) - 1px)}
|
||||
.issue-table th:last-child{border-top-right-radius:calc(var(--radius-xl) - 1px)}
|
||||
.issue-table tbody tr:last-child td:first-child{border-bottom-left-radius:calc(var(--radius-xl) - 1px)}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* выбор проекта, служебные состояния выбора и окно «Новый проект» */
|
||||
.appbar .project-picker { max-width: 280px; min-width: 160px; }
|
||||
.sidebar .project-picker { width: 100%; min-width: 0; }
|
||||
.project-gate { max-width: 640px; margin: var(--space-6) 0; }
|
||||
.project-gate .ui-notice { align-items: center; }
|
||||
.project-form { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
@ -111,7 +111,7 @@ describe("проект в приложении", () => {
|
||||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||||
// пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается
|
||||
expect(await within(screen.getByRole("banner")).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
|
||||
expect(await within(screen.getByRole("complementary", { name: "Панель приложения" })).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
|
||||
expect(h.get("/batches").length).toBe(listed);
|
||||
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
|
||||
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { withProject } from "../projects/current.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
import { Menu, MenuItem, Segmented, ThemeSwitch } from "../ui/index.ts";
|
||||
import { DataStatus } from "./DataStatus.tsx";
|
||||
|
||||
export type Section = "/board" | "/issues";
|
||||
|
||||
// верхняя панель (design.md, решение 9); section = null на путях вне разделов — активной ссылки нет (T5.1): название, проект, разделы, состояние данных, тема, меню «Ещё»
|
||||
export function AppBar({ section, project }: { section: Section | null; project: string | null }) {
|
||||
const qc = useQueryClient();
|
||||
const href = (to: Section) => (project ? to + withProject("", project) : to);
|
||||
return (
|
||||
<header className="appbar">
|
||||
<span className="appbar__brand">Хаб Jira</span>
|
||||
<ProjectPicker />
|
||||
<Segmented label="Раздел" value={section}
|
||||
options={[{ value: "/board", label: "Доска связей", href: href("/board") }, { value: "/issues", label: "Задачи", href: href("/issues") }]} />
|
||||
<span className="appbar__spacer" />
|
||||
<DataStatus project={project} />
|
||||
<ThemeSwitch />
|
||||
<Menu label="Ещё">
|
||||
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
|
||||
</Menu>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,31 +1,48 @@
|
||||
import { useBoard } from "../api/queries.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { Button, IconRefresh } from "../ui/index.ts";
|
||||
import { Button, IconButton, IconRefresh } from "../ui/index.ts";
|
||||
import { useJiraRefresh } from "./refresh.ts";
|
||||
import { refreshText } from "./refreshStatus.ts";
|
||||
|
||||
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета
|
||||
export function DataStatus({ project }: { project: string | null }) {
|
||||
const HINT = "Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira";
|
||||
|
||||
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета;
|
||||
// compact — свёрнутая боковая панель: кнопка-значок с точкой агента, тексты в подсказке (#69, design 3)
|
||||
export function DataStatus({ project, compact = false }: { project: string | null; compact?: boolean }) {
|
||||
const refresh = useJiraRefresh();
|
||||
const board = useBoard(project);
|
||||
const ls = board.data?.last_sync ?? null;
|
||||
const agent = refresh.agent;
|
||||
const rt = refresh.event ? refreshText(refresh.event) : null;
|
||||
const seen = agent?.last_seen_at ? `последний выход на связь: ${fmtStamp(agent.last_seen_at)}` : "выхода на связь ещё не было";
|
||||
const agentText = agent ? (agent.online ? "агент на связи" : "агент офлайн") : null;
|
||||
const syncText = board.data ? (ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было") : null;
|
||||
const event = <span className={`data-status__event${rt ? ` data-status__event--${rt.tone}` : ""}${compact ? " visually-hidden" : ""}`}
|
||||
role="status" title={rt?.text}>{rt?.text}</span>;
|
||||
if (compact) {
|
||||
const title = ["Обновить из Jira", agentText, syncText, rt?.text].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div className="data-status data-status--compact">
|
||||
<IconButton label="Обновить из Jira" title={title} disabled={refresh.busy} aria-busy={refresh.busy ? "true" : undefined}
|
||||
icon={refresh.busy ? <span className="ui-spinner" aria-hidden="true" /> : <IconRefresh />} onClick={() => void refresh.start()} />
|
||||
{agent && <span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />}
|
||||
{event}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="data-status">
|
||||
{agent && (
|
||||
<span className="data-status__agent" title={seen}>
|
||||
<span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />
|
||||
{agent.online ? "агент на связи" : "агент офлайн"}
|
||||
{agentText}
|
||||
</span>
|
||||
)}
|
||||
{board.data && <span className="data-status__sync">{ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было"}</span>}
|
||||
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()}
|
||||
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">
|
||||
{syncText && <span className="data-status__sync">{syncText}</span>}
|
||||
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()} title={HINT}>
|
||||
Обновить из Jira
|
||||
</Button>
|
||||
<span className={`data-status__event${rt ? ` data-status__event--${rt.tone}` : ""}`} role="status" title={rt?.text}>{rt?.text}</span>
|
||||
{event}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,10 +7,10 @@ import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
|
||||
import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts";
|
||||
import { NewProjectDialog } from "../projects/NewProjectDialog.tsx";
|
||||
import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts";
|
||||
import { AppBar } from "./AppBar.tsx";
|
||||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||||
import { PAGE_ROUTES } from "./pages.tsx";
|
||||
import { useJiraRefresh } from "./refresh.ts";
|
||||
import { Sidebar } from "./Sidebar.tsx";
|
||||
import "./shell.css";
|
||||
|
||||
export function Layout() {
|
||||
@ -78,10 +78,12 @@ export function Layout() {
|
||||
const refresh = useJiraRefresh();
|
||||
return (
|
||||
<ProjectContext value={ctx}>
|
||||
<AppBar section={section} project={okLabel} />
|
||||
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
<div className="shell">
|
||||
<Sidebar section={section} project={okLabel} />
|
||||
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
|
||||
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); refresh.track(batchId); select(p.label); }} />}
|
||||
</ProjectContext>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../App.tsx";
|
||||
import { getAuth, setToken } from "../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
@ -38,9 +38,9 @@ const open = (path: string) => {
|
||||
render(<App router={router} />);
|
||||
return router;
|
||||
};
|
||||
const bar = () => screen.getAllByRole("banner")[0]; // верхняя панель — первая; PageHeader страницы без <main> тоже даёт banner
|
||||
const bar = () => screen.getByRole("complementary", { name: "Панель приложения" });
|
||||
|
||||
describe("верхняя панель", () => {
|
||||
describe("боковая панель", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => {
|
||||
@ -56,7 +56,7 @@ describe("верхняя панель", () => {
|
||||
hub();
|
||||
open("/app/nowhere?project=AWPCA");
|
||||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Раздел" });
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
|
||||
const main = within(screen.getByRole("main"));
|
||||
@ -130,4 +130,113 @@ describe("верхняя панель", () => {
|
||||
await screen.findByText(/задач из/);
|
||||
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("разделы — в навигации «Разделы» боковой панели", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("«Свернуть панель» сворачивает и запоминает в jira-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" });
|
||||
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||
await userEvent.click(btn);
|
||||
expect(localStorage.getItem("jira-hub-sidebar")).toBe("collapsed");
|
||||
expect(bar()).toHaveClass("is-collapsed");
|
||||
cleanup();
|
||||
open("/app/board?project=AWPCA");
|
||||
expect(bar()).toHaveClass("is-collapsed");
|
||||
expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи");
|
||||
await userEvent.click(within(bar()).getByRole("button", { name: "Развернуть панель" }));
|
||||
expect(localStorage.getItem("jira-hub-sidebar")).toBeNull();
|
||||
expect(bar()).not.toHaveClass("is-collapsed");
|
||||
});
|
||||
|
||||
it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => {
|
||||
localStorage.setItem("jira-hub-sidebar", "collapsed");
|
||||
const h = hub();
|
||||
open("/app/issues?project=AWPCA");
|
||||
const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" });
|
||||
await userEvent.click(btn);
|
||||
await waitFor(() => expect(btn).toBeDisabled());
|
||||
expect(h.count("POST", "/refresh")).toBe(1);
|
||||
expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/);
|
||||
expect(btn.getAttribute("title")).toMatch(/пакет #7 выполняется/);
|
||||
});
|
||||
|
||||
it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => {
|
||||
localStorage.setItem("jira-hub-sidebar", "collapsed");
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" });
|
||||
await userEvent.click(tag);
|
||||
expect(bar()).not.toHaveClass("is-collapsed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("боковая панель на узком экране", () => {
|
||||
beforeEach(() => {
|
||||
setToken("tok"); localStorage.clear();
|
||||
vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("max-width: 639px"), media: q, addEventListener() {}, removeEventListener() {} }));
|
||||
});
|
||||
afterEach(() => { vi.unstubAllGlobals(); });
|
||||
|
||||
it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
const top = await screen.findByRole("banner", { name: "Строка приложения" });
|
||||
expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(within(top).getByText("Хаб Jira")).toBeInTheDocument();
|
||||
expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||
});
|
||||
|
||||
it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
const menu = await screen.findByRole("button", { name: "Меню" });
|
||||
await userEvent.click(menu);
|
||||
expect(menu).toHaveAttribute("aria-expanded", "true");
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus();
|
||||
expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||
expect(menu).toHaveFocus();
|
||||
});
|
||||
|
||||
it("выбор раздела закрывает панель и открывает раздел", async () => {
|
||||
hub();
|
||||
const router = open("/app/board?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||
await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" }));
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||
});
|
||||
|
||||
it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||
await userEvent.click(screen.getByTestId("sidebar-backdrop"));
|
||||
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Меню" }));
|
||||
await userEvent.click(within(bar()).getByRole("button", { name: "Закрыть меню" }));
|
||||
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||
});
|
||||
|
||||
it("I-1: событие пакета видно под строкой, не только в подсказке", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
const top = await screen.findByRole("banner", { name: "Строка приложения" });
|
||||
await userEvent.click(within(top).getByRole("button", { name: "Обновить из Jira" }));
|
||||
const strip = await screen.findByTestId("topbar-event");
|
||||
await waitFor(() => expect(strip).toHaveTextContent(/пакет #7 выполняется/));
|
||||
expect(strip).not.toHaveClass("visually-hidden");
|
||||
expect(strip).toHaveAttribute("aria-hidden", "true"); // озвучивает role=status кнопки, второй раз не нужно
|
||||
});
|
||||
});
|
||||
110
web/src/shell/Sidebar.tsx
Normal file
110
web/src/shell/Sidebar.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { withProject } from "../projects/current.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
import { IconBoard, IconButton, IconClose, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
||||
import { DataStatus } from "./DataStatus.tsx";
|
||||
import { useJiraRefresh } from "./refresh.ts";
|
||||
import { refreshText } from "./refreshStatus.ts";
|
||||
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
|
||||
|
||||
export type Section = "/board" | "/issues";
|
||||
|
||||
const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [
|
||||
{ to: "/board", label: "Доска связей", icon: <IconBoard /> },
|
||||
{ to: "/issues", label: "Задачи", icon: <IconList /> },
|
||||
];
|
||||
|
||||
// узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4)
|
||||
const NARROW = "(max-width: 639px)";
|
||||
const subscribe = (cb: () => void) => {
|
||||
if (typeof matchMedia !== "function") return () => {};
|
||||
const m = matchMedia(NARROW);
|
||||
m.addEventListener?.("change", cb);
|
||||
return () => m.removeEventListener?.("change", cb);
|
||||
};
|
||||
const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches;
|
||||
|
||||
type Props = { section: Section | null; project: string | null };
|
||||
|
||||
// боковая панель (#69, design 1–4); section = null на путях вне разделов — активной ссылки нет
|
||||
export function Sidebar(p: Props) {
|
||||
const narrow = useSyncExternalStore(subscribe, isNarrow);
|
||||
return narrow ? <NarrowShell {...p} /> : <Panel {...p} />;
|
||||
}
|
||||
|
||||
function NarrowShell(p: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuBtn = useRef<HTMLButtonElement>(null);
|
||||
// объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке
|
||||
const refresh = useJiraRefresh();
|
||||
const rt = refresh.event ? refreshText(refresh.event) : null; // I-1: на телефоне подсказок нет — событие пакета строкой под панелью
|
||||
const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []);
|
||||
return (
|
||||
<>
|
||||
<header className="topbar" aria-label="Строка приложения">
|
||||
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
|
||||
onClick={() => setOpen(true)}><IconMenu /></button>
|
||||
<span className="sidebar__brand">Хаб Jira</span>
|
||||
<span className="sidebar__spacer" />
|
||||
<DataStatus project={p.project} compact />
|
||||
</header>
|
||||
{rt && <div className={`topbar-event data-status__event--${rt.tone}`} data-testid="topbar-event" aria-hidden="true">{rt.text}</div>}
|
||||
{open && <>
|
||||
<div className="sidebar-backdrop" data-testid="sidebar-backdrop" onClick={drawer.close} />
|
||||
<Panel {...p} drawer={drawer} />
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, разделы, внизу состояние данных, тема, меню «Ещё»
|
||||
function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) {
|
||||
const qc = useQueryClient();
|
||||
const [stored, setCollapsed] = useState(readSidebar);
|
||||
const collapsed = !drawer && stored;
|
||||
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
|
||||
const href = (to: Section) => (project ? to + withProject("", project) : to);
|
||||
const first = useRef<HTMLAnchorElement>(null);
|
||||
useEffect(() => {
|
||||
if (!drawer) return;
|
||||
first.current?.focus();
|
||||
const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); };
|
||||
document.addEventListener("keydown", esc);
|
||||
return () => document.removeEventListener("keydown", esc);
|
||||
}, [drawer]);
|
||||
return (
|
||||
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
|
||||
<div className="sidebar__head">
|
||||
{!collapsed && <span className="sidebar__brand">Хаб Jira</span>}
|
||||
{drawer
|
||||
? <IconButton label="Закрыть меню" icon={<IconClose />} onClick={drawer.close} />
|
||||
: <IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
|
||||
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />}
|
||||
</div>
|
||||
{collapsed
|
||||
? project && (
|
||||
<button type="button" className="sidebar__project-tag" aria-label={`Проект ${project} — развернуть панель`}
|
||||
title={`Проект ${project}`} onClick={() => fold(false)}>{project.slice(0, 6)}</button>)
|
||||
: <ProjectPicker />}
|
||||
<nav className="sidebar__nav" aria-label="Разделы">
|
||||
{SECTIONS.map((s, i) => (
|
||||
<Link key={s.to} ref={i === 0 ? first : undefined} to={href(s.to)} className="sidebar__link"
|
||||
aria-current={section === s.to ? "page" : undefined} title={collapsed ? s.label : undefined} onClick={drawer?.navigated}>
|
||||
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<span className="sidebar__spacer" />
|
||||
<DataStatus project={project} compact={collapsed} />
|
||||
<div className="sidebar__foot">
|
||||
<ThemeSwitch />
|
||||
<Menu label="Ещё" up align={collapsed ? "start" : "end"}>
|
||||
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
|
||||
</Menu>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -1,38 +1,66 @@
|
||||
/* оболочка: верхняя панель разделов (design.md, решение 9) */
|
||||
.appbar{
|
||||
position:relative;
|
||||
display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;min-height:64px;padding:8px 24px;box-sizing:border-box;
|
||||
border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
|
||||
/* оболочка: боковая панель и страница (#69, design 1–3); прокрутка — у окна, панель липнет к его верху */
|
||||
.shell{display:grid;grid-template-columns:auto minmax(0,1fr);min-height:100vh}
|
||||
/* C-1: у элемента сетки с margin-inline:auto ширина — по содержимому; явная 100% держит страницу в своей дорожке */
|
||||
.shell>.page{width:100%;box-sizing:border-box}
|
||||
.sidebar{
|
||||
position:sticky;top:0;height:100vh;height:100dvh;width:240px;box-sizing:border-box;z-index:20;
|
||||
display:flex;flex-direction:column;gap:var(--space-4);padding:var(--space-4);
|
||||
border-right:1px solid var(--color-border-default);background:var(--color-bg-surface);
|
||||
}
|
||||
.appbar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap}
|
||||
.appbar__spacer{flex:1 1 auto}
|
||||
.data-status{display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.data-status__agent{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning)}
|
||||
.sidebar.is-collapsed{width:64px;padding-inline:var(--space-2);align-items:center}
|
||||
.sidebar__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h)}
|
||||
.sidebar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap;padding-left:var(--space-2)}
|
||||
.sidebar__project-tag{
|
||||
max-width:100%;padding:2px;border:1px solid var(--color-border-default);border-radius:var(--radius-xs);
|
||||
background:var(--color-bg-subtle);color:var(--color-text-secondary);font:inherit;font-size:var(--fs-caption);line-height:var(--lh-caption);
|
||||
font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;
|
||||
}
|
||||
.sidebar__project-tag:hover{color:var(--color-text-primary);border-color:var(--color-border-strong)}
|
||||
.sidebar__nav{display:flex;flex-direction:column;gap:var(--space-1);align-self:stretch}
|
||||
.sidebar__link{
|
||||
display:flex;align-items:center;gap:var(--space-3);height:var(--control-h-lg);padding:0 var(--space-3);box-sizing:border-box;
|
||||
border-radius:var(--control-radius);color:var(--color-text-secondary);text-decoration:none;font-weight:600;white-space:nowrap;
|
||||
}
|
||||
.sidebar__link:hover{background:var(--color-bg-hover);color:var(--color-text-primary)}
|
||||
.sidebar__link[aria-current="page"]{background:var(--color-bg-selected);color:var(--color-action-primary)}
|
||||
.sidebar__link:focus-visible,.sidebar__project-tag:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
.is-collapsed .sidebar__nav{align-items:center}
|
||||
.is-collapsed .sidebar__link{width:var(--control-h-lg);padding:0;justify-content:center}
|
||||
.sidebar__spacer{flex:1 1 auto}
|
||||
.sidebar__foot{
|
||||
display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);align-self:stretch;
|
||||
padding-top:var(--space-3);border-top:1px solid var(--color-border-default);
|
||||
}
|
||||
.is-collapsed .sidebar__foot{flex-direction:column}
|
||||
/* блок данных Jira: в развёрнутой панели — колонкой, событие пакета под кнопкой с переносом */
|
||||
.data-status{display:flex;flex-direction:column;align-items:stretch;gap:var(--space-2);align-self:stretch;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.data-status .ui-btn{width:100%;justify-content:center}
|
||||
.data-status__agent{display:inline-flex;align-items:center;gap:6px}
|
||||
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning);flex:0 0 auto}
|
||||
.data-status__dot.is-online{background:var(--color-status-success)}
|
||||
.data-status__sync{white-space:nowrap}
|
||||
/* I-3: живой регион всегда в DOM; ≥1100 текст события — отдельной строкой у нижней границы панели (кнопка не двигается, высота панели не меняется) */
|
||||
.data-status__event{font-weight:600;min-width:0}
|
||||
.data-status__event:empty{position:absolute}
|
||||
.data-status__event{font-weight:600;overflow-wrap:anywhere}
|
||||
.data-status__event:empty{position:absolute} /* живой регион всегда в DOM; пустой не занимает места */
|
||||
.data-status__event--info{color:var(--color-text-secondary)}
|
||||
.data-status__event--success{color:var(--color-status-success)}
|
||||
.data-status__event--warning{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))}
|
||||
.data-status__event--error{color:var(--color-status-error)}
|
||||
.data-status--compact{position:relative;align-items:center;align-self:center}
|
||||
.data-status--compact .data-status__dot{position:absolute;top:2px;right:2px;pointer-events:none;box-shadow:0 0 0 2px var(--color-bg-surface)}
|
||||
.crash{max-width:640px;margin:48px auto}
|
||||
.crash h2{margin:0;font:inherit}
|
||||
@media (min-width:1100px){
|
||||
.data-status__event{position:absolute;right:24px;bottom:1px;max-width:min(560px,45vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:14px}
|
||||
}
|
||||
@media (max-width:1099px){
|
||||
.appbar .data-status{order:10;flex:1 0 100%}
|
||||
.appbar .ui-theme,.appbar .more{order:4} /* T10.1: тема и «…» остаются в первой строке, статус данных — второй */
|
||||
.appbar .appbar__spacer{flex:1 1 0}
|
||||
/* узкий экран (<640 px): закреплённая строка с «Меню» и выезжающая панель поверх страницы (design 4) */
|
||||
.topbar{
|
||||
position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--space-2);height:56px;box-sizing:border-box;
|
||||
padding:0 var(--space-4) 0 var(--space-2);border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
|
||||
}
|
||||
.topbar .sidebar__brand{padding-left:0}
|
||||
.sidebar-backdrop{position:fixed;inset:0;z-index:30;background:var(--color-overlay)}
|
||||
.sidebar.is-drawer{position:fixed;left:0;top:0;bottom:0;z-index:31;width:min(300px,85vw);height:auto;box-shadow:var(--shadow-popover)}
|
||||
@media (max-width:639px){
|
||||
.appbar{padding-inline:16px}
|
||||
.appbar__brand{display:none}
|
||||
.appbar .appbar__spacer{display:none}
|
||||
.appbar .project-picker{flex:1 1 0;width:auto;max-width:none;min-width:0} /* T10.1: picker, тема и «…» — в одной строке */
|
||||
.appbar .ui-seg{flex:1 1 100%;order:5}
|
||||
.appbar .ui-seg__item{flex:1 1 0;justify-content:center}
|
||||
.shell{display:block}
|
||||
:root{--topbar-h:56px}
|
||||
}
|
||||
.topbar-event{
|
||||
position:sticky;top:56px;z-index:19;padding:var(--space-2) var(--space-4);border-bottom:1px solid var(--color-border-default);
|
||||
background:var(--color-bg-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;overflow-wrap:anywhere;
|
||||
}
|
||||
|
||||
13
web/src/shell/sidebarPref.ts
Normal file
13
web/src/shell/sidebarPref.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// свёртка боковой панели: "collapsed" — свёрнута, без записи — развёрнута; ошибки хранилища глотаем
|
||||
const KEY = "jira-hub-sidebar";
|
||||
|
||||
export function readSidebar(): boolean {
|
||||
try { return localStorage.getItem(KEY) === "collapsed"; } catch { return false; }
|
||||
}
|
||||
|
||||
export function saveSidebar(collapsed: boolean): void {
|
||||
try {
|
||||
if (collapsed) localStorage.setItem(KEY, "collapsed");
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch { /* хранилище недоступно — состояние только в памяти */ }
|
||||
}
|
||||
@ -3,8 +3,11 @@ html,body{min-height:100%}
|
||||
body{font:var(--fs-body)/var(--lh-body) var(--font-ui);background:var(--color-bg-canvas);color:var(--color-text-primary);margin:0}
|
||||
h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;margin:0}
|
||||
[hidden]{display:none!important}
|
||||
.page{padding-inline:24px;padding-block:0 32px;max-width:1440px;margin-inline:auto}
|
||||
/* страница — контейнер: раскладка страниц зависит от её ширины, а не окна (рядом боковая панель, #69) */
|
||||
.page{padding-inline:24px;padding-block:0 32px;max-width:1440px;margin-inline:auto;container:page/inline-size}
|
||||
.page--board{max-width:none}
|
||||
@media (max-width:639px){.page{padding-inline:16px}}
|
||||
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
|
||||
/* текст только для экранного чтения */
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
|
||||
|
||||
@ -43,7 +43,6 @@
|
||||
--p-low: #a3a39b;
|
||||
--edge: #a3a39b;
|
||||
--guide: #c2410c;
|
||||
--lane-alt: #f5f5f2;
|
||||
--edge-strong: var(--color-action-primary);
|
||||
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
|
||||
--space-5: 20px; --space-6: 24px; --space-7: 28px; --space-8: 32px;
|
||||
@ -100,7 +99,6 @@
|
||||
--p-low: #6c726b;
|
||||
--edge: #6c726b;
|
||||
--guide: #f08a5a;
|
||||
--lane-alt: #252525;
|
||||
--edge-strong: var(--color-action-primary);
|
||||
}
|
||||
}
|
||||
@ -147,6 +145,5 @@
|
||||
--p-low: #6c726b;
|
||||
--edge: #6c726b;
|
||||
--guide: #f08a5a;
|
||||
--lane-alt: #252525;
|
||||
--edge-strong: var(--color-action-primary);
|
||||
}
|
||||
|
||||
@ -63,3 +63,10 @@ describe("токены AURA", () => {
|
||||
expect(block(css, ':root[data-theme="dark"]')).toContain("color-scheme: dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("доска без чередования дорожек (#69, design 9)", () => {
|
||||
it("токена --lane-alt нет, правила чётной дорожки нет", () => {
|
||||
expect(readFileSync(TOKENS, "utf8")).not.toContain("--lane-alt");
|
||||
expect(readFileSync(resolve(SRC, "board/board.css"), "utf8")).not.toMatch(/\.lane:nth-child/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,9 +17,9 @@ export function Button({ variant = "secondary", size = "md", icon, loading, clas
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ label, icon, className, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
export function IconButton({ label, icon, className, title, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button {...rest} type={type} className={["ui-icon-btn", className].filter(Boolean).join(" ")} aria-label={label} title={label}>
|
||||
<button {...rest} type={type} className={["ui-icon-btn", className].filter(Boolean).join(" ")} aria-label={label} title={title ?? label}>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -25,11 +25,11 @@ export function Dropdown({ button, className = "", menu = false, children, onOpe
|
||||
}
|
||||
|
||||
// меню «Ещё»: Dropdown с кнопкой-иконкой; пункты — кнопки (aria-pressed для переключателей)
|
||||
export function Menu({ label, children, align = "end" }: {
|
||||
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end";
|
||||
export function Menu({ label, children, align = "end", up = false }: {
|
||||
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end"; up?: boolean; // up — открывается вверх (низ боковой панели)
|
||||
}) {
|
||||
return (
|
||||
<Dropdown menu className={"more" + (align === "start" ? " dd--start" : "")}
|
||||
<Dropdown menu className={"more" + (align === "start" ? " dd--start" : "") + (up ? " dd--up" : "")}
|
||||
button={({ open, toggle }) => <IconButton label={label} icon={<IconMore />} aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
|
||||
@ -32,3 +32,7 @@ export const IconNeighbors = make(<><circle cx="12" cy="12" r="2.5"/><circle cx=
|
||||
export const IconLink = make(<><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></>);
|
||||
export const IconLegend = make(<><path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".8"/><circle cx="4.5" cy="12" r=".8"/><circle cx="4.5" cy="18" r=".8"/></>);
|
||||
export const IconOpen = make(<><path d="M5 12h14"/><path d="M13 6l6 6-6 6"/></>);
|
||||
export const IconBoard = make(<><rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="8.5" y="15" width="7" height="6" rx="1.5"/><path d="M10 7h4M6.5 10v2.5h5.5V15"/></>);
|
||||
export const IconList = make(<><path d="M9 6h11M9 12h11M9 18h11"/><path d="M4 6h1M4 12h1M4 18h1"/></>);
|
||||
export const IconMenu = make(<><path d="M4 6h16M4 12h16M4 18h16"/></>);
|
||||
export const IconSidebar = make(<><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></>);
|
||||
|
||||
@ -203,6 +203,7 @@
|
||||
.dd-empty { padding: var(--space-2); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); }
|
||||
.dd-foot { display: flex; gap: var(--space-2); justify-content: space-between; border-top: 1px solid var(--color-border-default); padding-top: var(--space-2); }
|
||||
.dd--start > .dd-menu.menu { left: 0; right: auto; }
|
||||
.dd--up > .dd-menu { top: auto; bottom: calc(100% + var(--space-1)); }
|
||||
.menu button {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%;
|
||||
min-height: var(--control-h); padding: var(--space-1) var(--space-3); border: 0; border-radius: var(--radius-sm);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user