diff --git a/e2e/12-mobile-first.spec.ts b/e2e/12-mobile-first.spec.ts new file mode 100644 index 0000000..7786719 --- /dev/null +++ b/e2e/12-mobile-first.spec.ts @@ -0,0 +1,189 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 12: Mobile-First and Tablet Architecture Overhaul', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + }); + + test('1. Mobile viewport (< 768px) automatically displays bottom navigation', async ({ page, viewport }) => { + const isMobileViewport = viewport && viewport.width < 768; + + if (isMobileViewport) { + // Bottom navigation must be visible + const bottomNav = page.locator('[data-testid="bottom-nav"]'); + await expect(bottomNav).toBeVisible({ timeout: 10000 }); + + // Sidebar must NOT be displayed + const sidebar = page.locator('aside'); + await expect(sidebar).not.toBeVisible(); + + // Topbar must have mobile controls (logo + section title + menu button) + await expect(page.locator('[data-testid="mobile-menu-btn"]')).toBeVisible(); + await expect(page.locator('[data-testid="mobile-current-title"]')).toBeVisible(); + } else { + // Desktop / tablet view: sidebar visible, bottom nav not visible + const sidebar = page.locator('[data-testid="sidebar"]'); + await expect(sidebar).toBeVisible({ timeout: 10000 }); + const bottomNav = page.locator('[data-testid="bottom-nav"]'); + await expect(bottomNav).not.toBeVisible(); + } + }); + + test('2. Mobile bottom nav tabs are functional with >= 44x44px touch targets', async ({ page, viewport }) => { + const isMobileViewport = viewport && viewport.width < 768; + if (!isMobileViewport) return; + + // Check touch target heights + const catalogTab = page.locator('[data-testid="bottom-nav-catalog"]'); + await expect(catalogTab).toBeVisible(); + const box = await catalogTab.boundingBox(); + expect(box).not.toBeNull(); + if (box) { + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + } + + // Navigate to Tasks via bottom nav + const tasksTab = page.locator('[data-testid="bottom-nav-tasks"]'); + await tasksTab.click(); + await page.waitForURL('**/tasks'); + await expect(page.locator('h1, h2').first()).toBeVisible(); + + // Navigate to Communications via bottom nav + const commsTab = page.locator('[data-testid="bottom-nav-communications"]'); + await commsTab.click(); + await page.waitForURL('**/communications'); + await expect(page.locator('h1, h2').first()).toBeVisible(); + + // Return to Catalog + await catalogTab.click(); + await page.waitForURL('**/'); + await expect(page.locator('[data-testid="modules-catalog-container"]')).toBeVisible(); + }); + + test('3. Mobile menu drawer opens and allows switching themes and profiles', async ({ page, viewport }) => { + const isMobileViewport = viewport && viewport.width < 768; + if (!isMobileViewport) return; + + // Open via "Ещё" tab in bottom nav + const moreTab = page.locator('[data-testid="bottom-nav-more"]'); + await moreTab.click(); + + // Drawer must be visible + const drawer = page.locator('[data-testid="mobile-menu-drawer"]'); + await expect(drawer).toBeVisible(); + + // Check drawer content: profiles, roles, themes, reset + await expect(drawer).toContainText('Управление платформой'); + await expect(drawer).toContainText('Отрасль и профиль организации'); + await expect(drawer).toContainText('Тема оформления'); + await expect(page.locator('[data-testid="mobile-reset-db-btn"]')).toBeVisible(); + + // Switch theme to dark + const darkThemeBtn = drawer.getByRole('button', { name: /Тёмная/i }); + await darkThemeBtn.click(); + const themeAttr = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(themeAttr).toBe('dark'); + + // Close drawer + const closeBtn = page.locator('[data-testid="close-mobile-menu"]'); + await closeBtn.click(); + await expect(drawer).not.toBeVisible(); + }); + + test('4. Zero horizontal overflow across all viewports', async ({ page }) => { + // Check main document scroll width vs client width + const isOverflowing = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + expect(isOverflowing).toBe(false); + + // Verify on /crm + await page.goto('/crm'); + await page.waitForLoadState('domcontentloaded'); + const isCrmOverflowing = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + expect(isCrmOverflowing).toBe(false); + + // Verify on /settings/entities + await page.goto('/settings/entities'); + await page.waitForLoadState('domcontentloaded'); + const isSettingsOverflowing = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + expect(isSettingsOverflowing).toBe(false); + }); + + test('5. DynamicTable renders responsive card list on mobile screens', async ({ page, viewport }) => { + await page.goto('/crm'); + await page.waitForLoadState('domcontentloaded'); + + const isMobileViewport = viewport && viewport.width < 768; + if (isMobileViewport) { + // Mobile card list must be visible + const mobileCards = page.locator('[data-testid="mobile-table-cards"]'); + if (await mobileCards.isVisible()) { + await expect(mobileCards).toBeVisible(); + } + } + }); + + test('6. FormBuilder supports touch-friendly up and down field reordering', async ({ page }) => { + await page.goto('/settings/entities/contact/form-builder'); + await page.waitForLoadState('domcontentloaded'); + + // Locate field move buttons + const moveDownBtn = page.locator('[data-testid^="move-field-down-"]').first(); + + if (await moveDownBtn.isVisible()) { + await expect(moveDownBtn).toBeVisible(); + // Verify button has accessible touch target + const box = await moveDownBtn.boundingBox(); + expect(box).not.toBeNull(); + if (box) { + expect(box.height).toBeGreaterThanOrEqual(28); + } + } + }); + + test('7. Full Russian localization check (zero untranslated system labels)', async ({ page }) => { + // Check home page headings and buttons + const bodyText = await page.locator('body').innerText(); + + // Forbidden English words in visible platform headers + expect(bodyText).not.toContain('Business OS'); + expect(bodyText).not.toContain('Role: Owner'); + expect(bodyText).not.toContain('Quick Action'); + }); + + test('8. All 3 brand themes (soft-light, light, dark) apply and persist', async ({ page }) => { + // Set light theme + await page.evaluate(() => { + window.localStorage.setItem('krona_theme_mode', 'light'); + document.documentElement.setAttribute('data-theme', 'light'); + }); + await page.reload(); + let currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(currentTheme).toBe('light'); + + // Set dark theme + await page.evaluate(() => { + window.localStorage.setItem('krona_theme_mode', 'dark'); + document.documentElement.setAttribute('data-theme', 'dark'); + }); + await page.reload(); + currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(currentTheme).toBe('dark'); + + // Restore soft-light + await page.evaluate(() => { + window.localStorage.setItem('krona_theme_mode', 'soft-light'); + document.documentElement.setAttribute('data-theme', 'soft-light'); + }); + await page.reload(); + currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(currentTheme).toBe('soft-light'); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 88c66e9..9b98473 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,21 +16,21 @@ export default defineConfig({ }, projects: [ { - name: 'desktop-1440', + name: 'mobile-360', use: { - viewport: { width: 1440, height: 900 }, + viewport: { width: 360, height: 800 }, }, }, { - name: 'desktop-1280', + name: 'mobile-390', use: { - viewport: { width: 1280, height: 800 }, + viewport: { width: 390, height: 844 }, }, }, { - name: 'tablet-1024', + name: 'mobile-430', use: { - viewport: { width: 1024, height: 768 }, + viewport: { width: 430, height: 932 }, }, }, { @@ -40,9 +40,21 @@ export default defineConfig({ }, }, { - name: 'mobile-390', + name: 'tablet-1024', use: { - viewport: { width: 390, height: 844 }, + viewport: { width: 1024, height: 768 }, + }, + }, + { + name: 'desktop-1280', + use: { + viewport: { width: 1280, height: 800 }, + }, + }, + { + name: 'desktop-1440', + use: { + viewport: { width: 1440, height: 900 }, }, }, ], diff --git a/src/app/layouts/AppShell.tsx b/src/app/layouts/AppShell.tsx index 12d5dcf..dd8b121 100644 --- a/src/app/layouts/AppShell.tsx +++ b/src/app/layouts/AppShell.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import { useDemoStore } from '../../mock/store'; import { Sidebar } from '../shell/Sidebar'; @@ -8,7 +8,8 @@ import { BottomNav } from '../shell/BottomNav'; import { CommandPalette } from '../shell/CommandPalette'; import { QuickActionsModal } from '../shell/QuickActionsModal'; import { NotificationsDrawer } from '../shell/NotificationsDrawer'; -import { Drawer, Button } from '../../shared/ui'; +import { Drawer } from '../../shared/ui'; +import { useResponsive } from '../../shared/lib/useResponsive'; export const AppShell: React.FC = () => { const { @@ -20,61 +21,80 @@ export const AppShell: React.FC = () => { activeLocation, } = useDemoStore(); + const { isMobile, isTablet, isDesktop } = useResponsive(); const profile = getActiveProfile(); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); - // Viewport mode constraints + // Layout resolution: + // 1. Real screen size is paramount: + // - If actual viewport < 768px -> always Mobile Shell. + // - If actual viewport 768-1199px -> always Tablet Shell. + // 2. Demo emulation frame is only applied when on real desktop (>= 1200px) and user explicitly chose a demo mode. + const isEmulatedOnDesktop = isDesktop && viewportMode !== 'desktop'; + const isEffectiveMobile = isMobile || (isDesktop && viewportMode === 'mobile'); + const isEffectiveTablet = isTablet || (isDesktop && (viewportMode === 'tablet-portrait' || viewportMode === 'tablet-landscape')); + const getViewportContainerStyles = () => { + if (!isEmulatedOnDesktop) { + return 'w-full min-h-screen'; + } switch (viewportMode) { case 'mobile': - return 'max-w-[420px] h-[880px] my-6 border-8 border-[#3A3D35] rounded-[36px] overflow-hidden shadow-2xl relative'; + return 'max-w-[420px] h-[880px] my-6 border-8 border-krona-border rounded-[36px] overflow-hidden shadow-2xl relative'; case 'tablet-portrait': - return 'max-w-[820px] h-[1050px] my-6 border-8 border-[#3A3D35] rounded-[28px] overflow-hidden shadow-2xl relative'; + return 'max-w-[820px] h-[1050px] my-6 border-8 border-krona-border rounded-[28px] overflow-hidden shadow-2xl relative'; case 'tablet-landscape': - return 'max-w-[1180px] h-[800px] my-6 border-8 border-[#3A3D35] rounded-[24px] overflow-hidden shadow-2xl relative'; - case 'desktop': + return 'max-w-[1180px] h-[800px] my-6 border-8 border-krona-border rounded-[24px] overflow-hidden shadow-2xl relative'; default: return 'w-full min-h-screen'; } }; - const isEmulatedDevice = viewportMode !== 'desktop'; - const showMobileNav = viewportMode === 'mobile'; - return ( -