Per /Users/zimin/Downloads/Ordinis (4)/redesign/compact.html.
TopBar breadcrumb:
- Detail routes теперь показывают `← Справочники / Космические аппараты`
с back-arrow prefix на parent crumb (caption text, mute). Bold title +
inline mono subtitle "satellites · v1.0.0".
- Catalog route: simple "Dictionaries" title.
TopBar right cluster cleanup:
- Removed `Docs` link — diagnostic, не critical UX, моя кноп не нужна
для UI flow.
- Removed `VersionBadge` (commit + tag) — был noise рядом с продакшн
features. Build info всё ещё доступна в browser console / Docs.
- ThemeSwitch: tri-state (Light/Dark/System icons 3×24px) заменён на
2-button `Earth | Dark` с text labels (per prototype). `system`
preference остаётся в localStorage для existing users, но новый UI
не выставляет.
- LanguageSwitch: dual radio `RU | EN` заменён на single toggle button —
показывает текущий язык как label, click переключает на следующий.
Saves ~60px горизонтально, matches prototype.
EditorInfoPanel order:
- Action buttons (Экспорт / Time-travel / AOI) переехали из середины
panel (между metadata и Relations) в bottom (после Relations) —
per prototype. Info → meta → relations → actions flow.
- Action order: Экспорт первым (frequent), затем Time-travel, AOI последним
(rare для most dicts).
Per /Users/zimin/Downloads/Ordinis (4)/redesign/compact.html (бэкграунд
прототипа уже #faf9f5 — match'ит наш neutral default).
Catalog table:
- ИЗМЕНЁН column показывает relative time: Xс / Xмин / Xч / Xд / Xмес / Xг.
Прошлая абсолютная дата (05/06/2026) затирала scale ("5 минут vs 3 месяца —
визуально одинаково далеко"). Прототип использует 2ч, 3д, 1мес.
- Chevron `›` column добавлен в конец каждой строки — affordance что row
кликабелен (открывает editor).
Sidebar:
- Flatten: убраны section labels (workflow / admin). Все nav items в одном
списке per redesign: Главная → Справочники → Мои черновики → На ревью →
Outbox → Webhooks → Аудит → Поиск.
- /graph удалён из sidebar — теперь reachable только через catalog toolbar
"Граф ⇄" button (single entry point, no duplication).
- Применено в обоих Sidebar (desktop) и MobileSidebar (drawer).
Cleanup: убран unused GitBranch icon import.
Когда PUT /records/{key} возвращает 409 (optimistic-lock fail — другой
пользователь сохранил тот же record между нашим load и submit'ом),
открываем ConflictDiffModal.
Component:
- Header: 'Конфликт сохранения · {DICT}' + business key
- Diff body: refetch server state via useRecordRaw, иди по union ключей
local + server, оставь только отличающиеся. Per-row: field name (mono)
+ ваше значение (accent-bg) + серверное (surface-2)
- Footer 3 actions:
- Отмена: close, оставить editor open
- Загрузить серверное: discard local, refetch records, close editor
- Перезаписать: re-submit current payload (если опять 409 → новый diff)
- Backend-friendly: не требует server diff endpoint, client считает diff
client-side через JSON.stringify equality
Route wiring:
- onError handler distinguishes generic 409 (open diff modal) от
специальных кодов: x_references_blocked_by_dependents,
x_references_cascade_required, draft_required — те идут в свои dialogs
- Conflict state ({payload, businessKey}) capture'ит local data чтобы
показать в diff даже после reset формы
UI redesign matching prototype (Ordinis (3) dump):
- ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) +
column toggles + encoding/delimiter + preview filename per redesign line
1059-1092. CSV uses backend mutation, JSON client-side from filtered rows,
Excel/SQL disabled (backend pending).
- TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick
presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день
+ slider + tabs (Что изменилось / Записи на момент / Структура и поля) +
"Открыть как readonly" CTA. Replaces inline TimeTravelPicker.
- InfoPanel ← используют merge: rich rows с field path + active records
count + onClose policy chip (BLOCK/WARN/CASCADE). Inline
DictionaryDependentsPanel above records table removed (one source of truth).
- Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner
faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em
+ MDM smaller mute 0.32em.
- Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default
(per user preference, Earth cream still described in comment как opt-in).
- WorkflowBanner: Case 1 Live (approvalRequired=false) returns null —
permanent "Опубликовано" banner = noise, status already visible в
InfoPanel scope/version/updatedAt. Banner показывается только когда
есть actionable state.
Critical bug fixes:
- fix(CascadeConfirmDialog): infinite render loop "Maximum update depth
exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в
deps — wrapper identity changes every render → effect fires → reset() →
store update → render → loop. Extract stable `reset` reference перед
useEffect. Симптомы у user: создание/редактирование записи не работали.
- fix(semantic-release): add missing peer dep
conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+
больше не bundles preset. Без неё release job падал с
"Cannot find module 'conventional-changelog-conventionalcommits'".
User feedback: 'Time-travel label такое себе Time-travel… оставь'.
Reverting MR !70 part of rename ('Таймлайн' → 'Time-travel…').
Per handoff prototype line 299 это и был 'Time-travel…' изначально —
надо было не трогать.
1) MODAL OFF-SCREEN (Schema/AOI/Confirm dialogs)
DialogContent had `inset-0 w-full h-full` + `min-[880px]:inset-auto`
конфликтовал — inset-0 winning по CSS specificity, modal приклеивался
к левому краю viewport вместо центра. User screenshot показал Schema edit
выходящим за левую границу экрана.
Fix: remove inset-0 fullscreen pattern. Use simple centered modal —
left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 + w-[95vw] max-h-[95vh]
overflow-y-auto. На mobile <880px max-w из size variant даёт ширину 95vw
(natural fit). На desktop max-w ограничивает до 440/560/720/880/920px.
2) LOGO 'ORDINIS MDM' С CIRCLE ICON (per redesign prototype)
Sidebar logo: 'ORDINIS' → circle icon (accent orbit ring + dot) +
'ORDINIS' bold + 'MDM' mute subtitle (Tektur). SVG inline — нет external
dep.
Collapsed mode: только circle icon, no text (uses Tektur ORDINIS dropped).
3) TIMELINE BUTTON LABEL
InfoPanel button 'Time-travel…' → 'Таймлайн' (per user feedback). Behaviour
не меняется — клик открывает picker. Inline таймлайн visualization
отложен (большой refactor, нужен product decision).
Verified:
- Dialog modal центрируется на десктоп
- Logo рендерится с svg + text
- Tests: 116 pass
Per /Users/zimin/Downloads/redesign/screens/03-editor-records.png редизайн
prototype editor layout отличается от current MVP:
1. TopBar absorbs editor title + version (was в PageHeader)
2. PageHeader удалён — title и breadcrumb теперь только в TopBar
3. Tab labels получили counts (Записи 127, Связи 6, Поля 12)
4. Action buttons распределены per role:
- Time-travel + AOI → InfoPanel bottom (close к data context)
- Schema edit + +Запись → tab toolbar right (close к tab actions)
Changes:
src/components/layout/TopBar.tsx
- Breadcrumb получил subtitle slot — mono '<name> · v<version>'
- useBreadcrumb fetches dict detail для editor route '/dictionaries/$name'
- Render: '← Справочники / Космические аппараты satellites · v1.0.0'
src/api/queries.ts
- useDictionaryDetail принимает name: string | undefined + enabled flag
- Guard для editor breadcrumb когда route не matches dict
src/routes/dictionaries.$name.tsx
- Removed <PageHeader> — title/breadcrumb теперь в TopBar
- EditorInfoPanel получает actions prop:
onTimeTravel + timeTravelActive + onAoi + aoiActive
- EditorTabBar получает counts (per-tab badge) + actions slot
(right-aligned Schema + +Запись buttons)
- extractOutgoingFkCount helper для relations count
src/components/editor/EditorInfoPanel.tsx
- actions prop с Time-travel + AOI buttons
- Rendered в panel выше Relations section, между metadata и FK list
- aria-pressed когда active (time-travel или AOI с filter)
src/components/editor/EditorTabBar (inline in route)
- counts prop: Partial<Record<EditorTab, number>>
- actions prop: ReactNode для right-aligned cluster
- inline count badge — text-mono text-mute next to label
Tests: 116 pass. TS strict clean.
Handoff prototype показывает «‹ Свернуть» button внизу sidebar — toggle
desktop rail в icon-only 56px. Persists в localStorage ord-sidebar-collapsed.
Sidebar component:
- State collapsed (localStorage 'ord-sidebar-collapsed' = '1')
- <aside width> transitions между w-[220px] и w-[56px] (200ms ease)
SidebarContent (DRY с MobileSidebar) получил props:
- collapsed?: boolean (default false)
- onToggleCollapsed?: () => void (undefined hides button — mobile drawer)
Когда collapsed:
- Logo: 'ORDINIS' → 'O' (single char, centered)
- Section labels (text-cap headers) — hidden
- Nav links: icon-only, centered, icon size 18px (vs 15px expanded);
title attribute с label для hover tooltip
- Badges (records count, pending) — hidden (нет места)
- User block: avatar only, name/email hidden; title attribute с full name
- Toggle button bottom: '‹ Свернуть' → '›' (icon-only)
Mobile (MobileSidebar Sheet) НЕ получает collapse — там Sheet даёт full
nav text всегда, close через × header.
A11y: aria-label / title для всех collapsed buttons. Click handler
sets localStorage + state — survives F5.
Tests: 116 pass. TS strict clean.
Closing 2 handoff gaps:
1) DRAWER TOOLBAR FILTERS (handoff Screen 3 line 279)
SchemaDrivenForm получил три new optional props:
- searchFilter (string) — case-insensitive substring против field key + label
- onlyFilled (boolean) — hide fields с empty data[key]
(null/undefined/''/false/0/empty-array/empty-object)
- onCounterChange (visible, total) — surface field counter parent'у
Filter применяется поверх bucketing — все три buckets (identity/description/
extra) фильтруются. UI рендерит filteredBuckets.X.map(...) вместо raw bucket'ов.
В sections layout (drawer use case) если section после filter empty —
sticky header не рендерится. Tabs layout backward-compat (filter применяется
но visually invisible если user в другом tab'е).
dictionaries.$name.tsx callsite:
- State: drawerSearch, drawerOnlyFilled, drawerCount {visible, total}
- Reset on drawer close
- RecordDrawer.toolbar prop wired только в edit mode (create — все поля
empty, 'только заполненные' даст 0/0)
- SchemaDrivenForm receives все три filter props
Result: user может typing 'mass' в drawer search → видит только mass_kg /
data_mass / etc.; toggle 'только заполненные' → hide unused optional fields.
2) RECORDS TABLE RESPONSIVE COLUMN HIDING (handoff line 466)
Records table в dictionaries.$name.tsx применяет hideBelow API из MR !59:
- businessKey: всегда visible
- name/code: hideBelow='md' (768px)
- extra schema columns: hideBelow='lg' (1024px)
- scope badge: hideBelow='sm' (640px)
- validFrom: hideBelow='lg'
- actions: всегда visible
На mobile 375px только businessKey + actions visible — chrome остаётся
usable. Tablet 768+ adds name/scope. Desktop 1024+ adds schema columns.
Tests: 116 pass.
Handoff prototype design/compact.html показывает editor как 3-col layout:
[Sidebar 220 (global nav)] [InfoPanel 220 (dict metadata)] [main content].
Текущая implementation была 2-col (nav + everything).
NEW src/components/editor/EditorInfoPanel.tsx:
- Scope badge (PUBLIC/INTERNAL/RESTRICTED) — top
- ОПИСАНИЕ section — caption + dict.description body
- Metadata 2x2 grid: Bundle / Версия / Записей / Локали
(mono text-ink-2, cap labels)
- СВЯЗИ section с двумя группами:
→ ссылается outgoing FK list (name links к /dictionaries/$name)
← используют incoming refBy list (deduplicated sourceDict names)
- sticky lg:top-2 self-start чтобы panel оставалась видна при scroll
- lg:max-h-[calc(100vh-7rem)] overflow-y-auto если metadata высокий
src/routes/dictionaries.$name.tsx:
- Обернул содержимое (banner + tabs + tab content) в:
<div className='grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6'>
<EditorInfoPanel ... />
<div>{banner + tabs + content}</div>
</div>
- На <lg переключается в stacked (info above content) — single column
благодаря grid-cols-1.
PageHeader остаётся выше grid (full-width breadcrumb + title + actions).
Action buttons (AOI / Time-travel / Schema edit / Создать) живут в
PageHeader.actions slot — handoff prototype показывает их в info panel,
но рядом с title лучше discoverable. Может уйти в info panel в future
refactor.
Tests: 116 pass. TS strict clean.
RecordDrawer теперь принимает optional toolbar prop:
- search (string) — field name filter
- onlyFilled (boolean) — show only filled fields toggle
- counterVisible/Total (number) — 'N / M полей' caption
Sticky position между header и body, h=44 per handoff style. Toolbar
rendered только если caller передал. SchemaDrivenForm callsite (записать
filter в каждое FieldRenderer) — separate follow-up, требует refactor
form internals (currently 3 buckets identity/description/extra).
Section anchor chips + wide-mode ToC rail — deferred до backend
x-section schema metadata (CEO open question #5 в handoff).
Per design_handoff_ordinis_mdm/README.md responsive table (line 464-465):
- ≤1024px: Sidebar becomes overlay drawer (hamburger в TopBar)
- ≤880px: Drawers и modals fullscreen (100vw × 100dvh, no border-radius)
Implementation:
src/components/layout/Sidebar.tsx
- Extracted SidebarContent (logo + nav + user block) для DRY
- Desktop <Sidebar /> wraps SidebarContent в hidden lg:flex aside
- New <MobileSidebar open onClose /> — Sheet с тем же содержимым,
260px wide, slides from left
- SidebarLink принимает onClick → drawer auto-close при navigate
src/components/layout/TopBar.tsx
- Optional onMenuClick prop — рендерит hamburger button (Menu icon
из lucide-react) с lg:hidden
- VersionBadge скрыт <md (768px) — diagnostic info, не critical UX
src/routes/__root.tsx
- useState mobileNavOpen, передаём в MobileSidebar + TopBar
src/ui/components/dialog.tsx
- SIZE_CLASSES получили prefix (arbitrary Tailwind v4
breakpoint per handoff exact 880px). На mobile <880px modal становится
fullscreen (inset-0 w-full h-full no rounded), ≥880px — centered с
translate + max-w из SIZE_CLASSES.
src/ui/components/sheet.tsx
- size variants: sm:max-w-* → min-[880px]:max-w-*. На mobile <880px
sheet берёт w-full (fullscreen drawer per handoff).
src/components/record/RecordDrawer.tsx
- max-width переключатель 520/880 теперь применяется только ≥880px.
На mobile drawer всегда fullscreen.
src/ui/components/language-switch.tsx
- Display fallback: opt.short → opt.label → uppercase id.
Previously показывал id ('ru-RU', 'en-US') когда short не был задан —
теперь использует label ('RU', 'EN') как fallback.
Browser-verified в preview viewport 375×812:
- Hamburger ☰ visible, click открывает Sheet с nav items
- Backdrop fade + slide-from-left animation
- TopBar fits в 375px (RU/EN compact, Sign in visible)
- LoadingBlock + Alert text-wrap correctly
Реальные измерения через DevTools на staging выявили 3 бага после MR !47:
Bug 1: text-title-md/lg/xl inherited Tektur от @nstart/ui Card parent.
Handoff: 'Inter for all headings'. Computed: Tektur.
Fix: добавил font-family: var(--font-sans) explicit на text-title-*
и text-body/text-cell. После Stage 3.9 (nstart gone) можно убрать —
body inherit будет достаточно.
Bug 2: text-cap + font-mono cohabit (catalog scope chips, time-travel
presets) → Tailwind v4 cascade order: font-mono идёт после text-cap
в bundled CSS → JetBrains Mono перебивает Tektur. text-cap baked
font-display, font-mono token redundant.
Fix: codemod strip font-mono где есть text-cap (4 sites). Computed
font-family теперь правильно Tektur.
Bug 3: @nstart/ui PageHeader рендерил h1 'DICTIONARIES' в Tektur
36px/700 uppercase (font-primary text-4xl). Handoff page title:
17px/600 Inter normal case. Наш src/ui/components/page-header.tsx
написан но не экспортирован через barrel — все routes резолвили
PageHeader к nstart версии.
Fix: добавил 'export { PageHeader }' override в src/ui/index.ts.
Все 7 PageHeader usages (catalog/search/reviews/webhooks/outbox/
my-drafts/graph) теперь получат handoff-conformant heading.
Bug 4 (small): catalog counter '37/37' использовал text-cap но это
numeric value — handoff role text-mono (11px JetBrains Mono).
Fix: text-cap → text-mono + tabular-nums для grid alignment.
Verified via injected test elements + CDP computed styles:
text-title-xl: 22/600 Inter
text-title-lg: 17/600 Inter
text-title-md: 15/600 Inter
text-body: 13/400 Inter
text-cell: 12.5/500 Inter
text-mono: 11/500 JetBrains Mono
text-cap: 10.5/600 Tektur
Все 7 utilities точно matches handoff scale.
Files: 4 modified. TS strict + 116 tests pass.
Stale package.json создавал ложное впечатление rollback'а: staging
показывал "v1.2.0" хотя последний tag — v1.2.2 (package.json не bump'ился
со времён v1.2.0 release).
Vote A — branch builds НЕ используют package.json как version label.
Vite определяет channel:
serverTag != none → что прод/staging RUNS
clientTag → что build'ил CI tag pipeline (CI_COMMIT_TAG set)
CI без tag → 'dev' literal (staging / branch CI build)
локально → 'local' literal (pnpm dev)
Detection через новый __APP_CI__ define из process.env.CI (GitLab сетает
CI=true). Local pnpm dev: CI undefined → channel='local'.
package.json.version всё ещё инжектится как __APP_VERSION__ и видна в
tooltip badge'а как 'floor vX.Y.Z' — не как primary label.
Files:
- vite.config.ts: + APP_CI from process.env.CI
- src/vite-env.d.ts: + __APP_CI__ declare
- src/components/version/useAppVersion.ts: + clientCI
- src/components/version/VersionBadge.tsx: channel logic вместо fallback
Verify all three modes:
local → 'local · {commit}'
CI branch → 'dev · {commit}'
CI tag v1.3.1 → 'v1.3.1 · {commit}'
Tests: 116 pass.
Dictionary editor route теперь имеет TabBar (handoff design Stage 3.4):
Records | Relations | Fields | JSON | Events | History
URL-synced через ?tab= (default: records). Backward-compat: старый
?view=hub auto-remap'нется в ?tab=relations при mount'е.
Tab contents:
- records (default): существующий records table + filters + edit modal
flow — без изменений, обёрнут в conditional render
- relations: DictionaryHubView (был раньше при view=hub)
- fields: schema properties summary — name/type/required/unique/i18n/FK/desc
- json: pretty-printed schemaJson read-only
- events: link к /audit с pre-filled dict filter
- history: placeholder (schema version timeline — backend endpoint pending)
EditorTabBar — inline component с handoff styling (border-b border-line +
active accent border + font-semibold), -mb-px overlap для clean look.
Hub view's neighbor cards теперь search={tab:'relations'} вместо
view='hub' (consistent с new param).
DictionaryDependentsPanel рендерится только в Records tab — auto Hub view
в Relations tab уже включает dependents через свой layout.
Tests: 116/116 PASS, build green, TS clean.
Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.
Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.
Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
* upsert() — early-return + skip counter если flag=false на dict.
* delete() — symmetric: skip если flag=false (защита от stale keys
после flag-flip; полный cleanup TODO Phase 2).
* New metric: ordinis_projection_records_skipped_total.
Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.
Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
Когда dict опт-инится в флаг, projection материализуется, но read-api
ещё не использует. Read routing — отдельная feature с benchmark proof
(через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.
Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
dictionaries). Default flag=false после migration → projection-writer
пропускает всё → Redis keys для existing dicts становятся stale.
Это OK потому что read-api НИКОГДА не читал из Redis (writes были
dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
материализацию. По default — нет. Admin opt-ins per-dict через UI.
Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
leaflet-draw 1.0.4 (последний release 2017) сломан с Leaflet 1.8+:
mousedown сразу триггерит mouseup, прямоугольник схлопывается в
точку при первом нажатии. На пользовательском скрине quadrate ~5px
вместо нарисованной области.
Заменил полностью на свою draw-implementation (~80 строк):
- Toolbar UI: 3 кнопки (Прямоугольник / Полигон / Очистить)
- Active mode highlights ultramarain background
- Rectangle: mousedown → preview → mouseup finalize. Map.dragging
отключается в rectangle mode чтобы drag не панил карту.
- Polygon: click добавляет вершину + dashed mouse-line preview к
курсору. Double-click closes (min 3 vertices). doubleClickZoom
disabled на map чтобы не конфликтовало.
- DrawMode read через ref чтобы listeners не пересоздавались на
каждом state change.
Удалены deps:
- leaflet-draw 1.0.4 (broken)
- @types/leaflet-draw
Bundle: -68KB minified / -23KB gzip (less broken legacy code).
Tests: 89 passed. i18n: RU/EN ключи для новой toolbar.
В Leaflet 1.9.4 default attribution содержит ukrainian flag emoji
('🇺🇦 Leaflet'). Для admin tool ЦУОД политически нейтральнее без него.
attributionControl: false выключает весь control (Leaflet + OSM
credit). OSM tile usage всё ещё legitimate — admin internal tool,
не публичный сервис.
DevTools на пользовательском скрине показал:
- Map div СУЩЕСТВУЕТ в DOM (display:block, height:520 inline)
- Реальный размер 974×254 — flex-shrink порезал до ~50% запрошенного
потому что parent (Modal body) overflow-hidden + max-h:calc(100vh-2rem)
на узком viewport (497px) не вмещал 520+ контента.
- Класса 'leaflet-container' на div НЕТ — L.map() так и не вызвался.
Корень: Modal от @nstart/ui рендерит children только после
useAnimatedPresence фликает isMounted=true (на след. рендер после
isOpen=true). Мой useEffect фаерится РАНЬШЕ — div ещё не в DOM,
mapDivRef.current=null. Старый guard 'if (!div) return' выходил
БЕЗ schedule RAF — больше никогда не пробовал.
Фикс в tryInit: schedule RAF и для null div, и для 0-size. Cap 100
кадров (~1.6s). Это finally доводит до L.map() когда div появится
и получит размер.
Plus: shrink-0 + min-height: 520 на map div чтобы flex-shrink не
ужимал до 254px на узких viewport. Visual quality 520px stable.
Console показывает no errors AND no tile requests. Это значит
L.map(div) либо не вызывается, либо вызывается с 0×0 контейнером
(Leaflet не запрашивает 0 tiles при 0-viewport).
Гипотеза: 'flex-1' + 'height: 60vh' конфликтуют с Modal transform
animation (translate-y-2 scale-[0.98] во время fade-in). На
момент useEffect div.clientHeight иногда читался 0 несмотря на
inline style.
Decouple полностью: hardcode '520px' pixel height, display:block,
width:100%. Не использует flex layout. Стабильно >0 на любом
viewport.
Предыдущий fix с ResizeObserver + RAF не помог: на пользовательском
скрине карта снова blank, нет даже zoom controls и attribution.
Гипотеза: Modal от @nstart/ui использует display:none → block для
animation. В момент useEffect div ещё имеет 0×0 (parent display:none
makes all children clientWidth/Height = 0 regardless of inline style).
Leaflet L.map(div) с 0×0 контейнером не вычисляет initial tile grid,
и invalidateSize позже не recompute'ит некоторые internal collections.
Решение: recursive RAF poll до тех пор пока div.clientWidth и
clientHeight > 0, тогда init'им L.map(). Cap на 100 кадров (~1.6s)
fallback init с тем что есть — на случай если modal никогда не
покажется по какой-то причине.
ResizeObserver всё равно ставится после init для window resize /
dev tools toggle / responsive — но не критичен для первого рендера.