Files
specs/openspec/changes/archive/2026-08-27-replace-loading-spinners-with-skeletons/design.md
T

163 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Frontend уже разделяет запросные экраны на container, ViewModel и отдельные
представления состояний. ViewModel отличает начальную загрузку без данных от
фонового обновления Client Cached Snapshot, а pending-состояния действий
остаются у соответствующих форм и controls. Сейчас эти разные состояния
визуально смешаны: top-level loading views и route fallback используют общий
spinner, некоторые частичные блоки показывают loading-текст, а auth guards до
завершения bootstrap не рендерят ничего.
Mantine уже является UI dependency и предоставляет Skeleton, layout и progress
primitives. Поэтому изменение должно остаться в presentation boundaries,
сохранить текущие ViewModel, query/cache и auth transitions и не вводить новый
loading framework.
## Goals / Non-Goals
**Goals:**
- Сделать initial, partial, lazy-route и auth-bootstrap loading states
структурно похожими на ожидаемую область содержимого.
- Сохранить единое доступное обозначение занятой области и исключить skeleton
placeholders из интерактивной и focus-модели.
- Оставить уже показанные данные и controls основным контекстом для cached
refresh и pending mutations.
- Удержать ответственность за форму skeleton рядом с соответствующим app,
page или section view.
**Non-Goals:**
- Изменение запросов, cache timing, state selection, auth/route decisions,
error/empty states или доменных данных.
- Замена компактной индикации внутри pending controls и рядом с видимым Client
Cached Snapshot.
- Новый UI framework, глобальная orchestration загрузки или редизайн loaded
screens.
- Изменение backend, API contract или persistence.
## Decisions
### 1. Loading presentation принадлежит существующим view boundaries
Top-level page loading остаётся отдельным state view, выбранным существующим
container по состоянию ViewModel. Каждый такой view строит skeleton из тех же
типов layout regions, что и соответствующий loaded screen: heading, cards,
filters, summaries или rows. Частичный skeleton принадлежит section view и
занимает только незагруженный блок, поэтому соседнее готовое содержимое не
скрывается.
Это сохраняет установленную границу `container -> ViewModel -> view` и позволяет
форме placeholder следовать реальной семантике экрана. Альтернатива — заменить
общий loading StateView одним универсальным skeleton — отклонена: универсальная
форма не может одновременно правдоподобно представлять список счетов,
Portfolio, ledger и локальные sections и быстро разойдётся с loaded layouts.
Общий StateView сохраняется для error и empty outcomes, но перестаёт владеть
initial loading. Его loading-вариант удаляется после перевода всех consumers,
чтобы spinner-based primary state нельзя было случайно продолжать использовать.
### 2. Используются установленные Mantine primitives без новой абстракции
Skeleton shapes собираются из Mantine Skeleton и текущих layout primitives.
Занятая область получает единый доступный status/busy contract и локализованное
имя, а сами визуальные placeholders остаются декоративными, не содержат fake
values и не создают focus targets. Custom animation не добавляется.
Низкоуровневая shared-обёртка допустима только для повторяющейся busy-семантики;
структура cards, sections и rows остаётся в page/entity presentation layers.
Альтернативы в виде собственной CSS skeleton-системы или новой dependency
отклонены, потому что установленная библиотека уже покрывает нужные primitives
и снижает surface поддержки.
### 3. Route loading и auth bootstrap остаются app-level concerns
Suspense boundaries в routing layer получают neutral structural fallbacks для
ожидаемой области: auth-card shape для public auth routes, application-content
shape внутри доступного shell и neutral shell shape там, где сам shell ещё не
доступен. Уже отрисованный application shell не заменяется при загрузке
дочернего route chunk.
Auth guards продолжают принимать решение только по существующему `ready` и
`user`. Пока `ready` ложен, они показывают neutral shell skeleton без profile,
navigation result или route content; после bootstrap текущий redirect/render
flow продолжается без изменений. Перенос bootstrap в router data APIs или
изменение auth ownership отклонены как несоразмерные визуальной задаче.
### 4. Contextual progress не переводится на skeleton
Button loading для submit/remove/update и компактные indicators фонового
обновления сохраняются. Когда Client Cached Snapshot существует, loaded/empty
view остаётся на экране, а текущая локальная refresh-индикация и recovery flow
продолжают работать рядом с ним. Skeleton выбирается только для состояния без
доступного результата либо для отдельно незагруженного content block.
Так state choice остаётся детерминированным: наличие usable content имеет
приоритет над skeleton. Альтернатива показывать skeleton поверх cached data
отклонена, потому что она скрывает полезный контекст и смешивает initial loading
с background refresh.
### 5. Проверяется наблюдаемая граница, а не разметка библиотеки
Risk-based UI coverage фиксирует выбор skeleton для initial/partial и auth/route
loading, сохранение loaded context для refresh/pending states и отсутствие
интерактивности у placeholders. Тесты не закрепляют внутренний DOM Mantine или
точные декоративные размеры; layout fidelity подтверждается scoped
presentation review на существующих responsive boundaries.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | App, page и section presentation boundaries используют структурные skeletons для initial/partial loading, сохраняя contextual progress и текущие state machines. |
| backend | `N/A` | Запросы, ответы, auth semantics, данные и server-side поведение не меняются. |
| specs | `affected` | Новый capability contract `frontend-loading-presentation` остаётся canonical описанием loading invariants. |
| docs | `affected` | После поставки as-built frontend и feature documentation отражает различие skeleton loading и contextual progress без копирования planning artifacts. |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `N/A` | Изменяется только frontend presentation; ни backend API, ни frontend contract snapshot не меняются. |
## Risks / Trade-offs
- **Skeleton расходится с loaded layout.** Page- и section-owned shapes следуют
тем же semantic regions и responsive containers; декоративные точные размеры
не превращаются в отдельный общий contract.
- **Skeleton ошибочно заменяет полезный cached context.** Существующая ViewModel
граница initial loading versus `isFetching` сохраняется, а contextual
indicators остаются отдельным presentation path.
- **Auth bootstrap показывает неверный пользовательский flow.** Neutral shell
не содержит profile или route outcome, а существующие guards остаются
единственным владельцем решения после `ready`.
- **Декоративные placeholders создают шум assistive technology.** Busy status
назначается области целиком; внутренние shapes не интерактивны и не выдают
fake content.
- **Больше специализированных view components.** Дополнительные небольшие views
приняты как цена точного соответствия разным экранам; общий generic skeleton
оставил бы меньше файлов, но больше условностей и visual drift.
## Migration Plan
1. Ввести app-level neutral shell/content fallbacks и согласовать общую
busy-семантику на существующих Mantine primitives.
2. Перевести top-level page loading views и partial section states на
принадлежащие им skeleton shapes, не меняя ViewModel и data flow.
3. Подключить route и auth-bootstrap fallbacks, сохранив текущие redirect и
session transitions.
4. Удалить устаревший generic spinner-loading path после отсутствия consumers;
compact mutation и cached-refresh indicators оставить без изменений.
5. После подтверждения поведения обновить durable as-built documentation.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Reason | Auth decision не меняется, но новый neutral fallback отображается до завершения session bootstrap и поэтому должен иметь явно безопасный путь отката. |
| Trigger | Bootstrap fallback показывает public/protected route content до `ready`, нарушает redirect после определения сессии либо создаёт блокирующую accessibility regression. |
| Actions | Вернуть auth guards к прежнему blank fallback, сохранив существующие `ready`/`user` decisions; остальные page и section skeletons можно оставить независимо. |
| Data safety | Откат меняет только presentation; session tokens, query cache, backend data и persistence не преобразуются. |
| Verification | Подтвердить отсутствие route content до `ready`, прежние public/protected redirects после bootstrap и успешный frontend canonical gate. |