diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index c0439b37f..fe6dc3f23 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -654,7 +654,7 @@ export async function saveSettings(page: Page): Promise { export async function goToDashboard(page: Page): Promise { const dashboardLink = page - .locator('a.brand[href$="/workspace/dashboard"]') + .getByRole('link', { name: 'Dashboard', exact: true }) .first(); await expect(dashboardLink).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 532d62d38..0f5104310 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -303,10 +303,15 @@ test.describe('Electron Settings', () => { exact: true, }) ).toHaveCount(0); - await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute( - 'href', - /\/workspace\/sources$/ - ); + await expect( + secondLaunch.mainWindow.getByRole('link', { + name: 'Sources', + exact: true, + }) + ).toHaveClass(/is-active/); + await expect( + secondLaunch.mainWindow.locator('button.rail-toggle') + ).toBeVisible(); } finally { await closeElectronApp(secondLaunch); } diff --git a/apps/electron-backend-e2e/src/smoke.e2e.ts b/apps/electron-backend-e2e/src/smoke.e2e.ts index cfd1af80e..87b8d6b70 100644 --- a/apps/electron-backend-e2e/src/smoke.e2e.ts +++ b/apps/electron-backend-e2e/src/smoke.e2e.ts @@ -57,6 +57,175 @@ test.describe('Electron App Smoke Test', () => { } }); + test('@critical @electron expands and collapses the labelled navigation rail', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const rail = app.mainWindow.locator('.app-rail'); + const toggle = app.mainWindow.getByRole('button', { + name: 'Expand navigation', + }); + + await expect( + app.mainWindow.getByRole('link', { + name: 'Dashboard', + exact: true, + }) + ).toHaveCount(1); + await expect(rail.locator('.rail-toggle-icon')).toHaveText( + 'arrow_drop_down' + ); + await expect(rail.locator('.rail-brand')).toHaveCount(0); + + await toggle.click(); + + await expect( + app.mainWindow.getByRole('button', { + name: 'Collapse navigation', + }) + ).toBeVisible(); + await expect(rail.locator('.rail-toggle-icon')).toHaveText( + 'arrow_right' + ); + await expect(rail.locator('.rail-brand-name')).toHaveText( + 'IPTVnator' + ); + await expect( + rail.locator('.portal-rail-link-label', { + hasText: 'Dashboard', + }) + ).toBeVisible(); + expect( + await app.mainWindow + .getByRole('link', { + name: 'Dashboard', + exact: true, + }) + .evaluate((link) => getComputedStyle(link, '::after').right) + ).toBe('3px'); + + const collapseToggle = app.mainWindow.getByRole('button', { + name: 'Collapse navigation', + }); + const toggleBackground = await collapseToggle.evaluate( + (button) => getComputedStyle(button).backgroundColor + ); + await collapseToggle.hover(); + await expect(collapseToggle.locator('.rail-toggle-icon')).toHaveCSS( + 'transform', + 'none' + ); + await expect(collapseToggle).toHaveCSS( + 'background-color', + toggleBackground + ); + + const sourcesLink = app.mainWindow.getByRole('link', { + name: 'Sources', + exact: true, + }); + const selectionBackground = await sourcesLink.evaluate(() => { + const probe = document.createElement('div'); + probe.style.background = 'var(--app-selection-surface)'; + document.body.append(probe); + const background = getComputedStyle(probe).backgroundColor; + probe.remove(); + return background; + }); + await sourcesLink.hover(); + await expect(sourcesLink).toHaveCSS( + 'background-color', + selectionBackground + ); + await expect + .poll(() => + sourcesLink.locator('mat-icon').evaluate((icon) => { + const transform = getComputedStyle(icon).transform; + return transform === 'none' + ? 1 + : new DOMMatrixReadOnly(transform).a; + }) + ) + .toBeCloseTo(2.1, 2); + await expect + .poll(() => + sourcesLink + .locator('mat-icon') + .boundingBox() + .then((box) => box?.width ?? Number.POSITIVE_INFINITY) + ) + .toBeLessThanOrEqual(44); + await expect( + app.mainWindow + .getByRole('link', { + name: 'Dashboard', + exact: true, + }) + .locator('mat-icon') + ).toHaveCSS('transform', 'none'); + await expect + .poll(() => + sourcesLink + .locator('.portal-rail-link-label') + .evaluate((label) => { + const transform = getComputedStyle(label).transform; + return transform === 'none' + ? 1 + : new DOMMatrixReadOnly(transform).a; + }) + ) + .toBe(1); + + const [magnifiedIconBox, sourcesLabelBox] = await Promise.all([ + sourcesLink.locator('mat-icon').boundingBox(), + sourcesLink + .locator('.portal-rail-link-label') + .boundingBox(), + ]); + expect(magnifiedIconBox).not.toBeNull(); + expect(sourcesLabelBox).not.toBeNull(); + expect( + (magnifiedIconBox?.x ?? 0) + + (magnifiedIconBox?.width ?? 0) + ).toBeLessThanOrEqual(sourcesLabelBox?.x ?? 0); + + await app.mainWindow.emulateMedia({ + reducedMotion: 'reduce', + }); + await expect(sourcesLink.locator('mat-icon')).toHaveCSS( + 'transform', + 'none' + ); + await app.mainWindow.emulateMedia({ + reducedMotion: 'no-preference', + }); + + await app.mainWindow + .getByRole('button', { name: 'Collapse navigation' }) + .click(); + + await expect(rail.locator('.rail-toggle-icon')).toHaveText( + 'arrow_drop_down' + ); + await expect(rail.locator('.rail-brand')).toHaveCount(0); + await sourcesLink.hover(); + await expect + .poll(() => + sourcesLink.locator('mat-icon').evaluate((icon) => { + const transform = getComputedStyle(icon).transform; + return transform === 'none' + ? 1 + : new DOMMatrixReadOnly(transform).a; + }) + ) + .toBeCloseTo(2.1, 2); + } finally { + await closeElectronApp(app); + } + }); + test('@critical @electron app should render workspace content', async ({ dataDir }) => { const app = await launchElectronApp(dataDir); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 4293e33aa..4dcd2b395 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "إظهار القائمة", + "COLLAPSE_NAVIGATION": "إخفاء القائمة", "RAIL_DASHBOARD": "لوحة التحكم", "OPEN_DASHBOARD": "فتح لوحة التحكم", "RAIL_SOURCES": "المصادر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 4aad181c8..5020df79d 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "وسّع التنقل", + "COLLAPSE_NAVIGATION": "طوي التنقل", "RAIL_DASHBOARD": "لوحة التحكم", "OPEN_DASHBOARD": "حل لوحة التحكم", "RAIL_SOURCES": "المصادر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 1e92b244f..93d8d6363 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Разгарнуць навігацыю", + "COLLAPSE_NAVIGATION": "Згарнуць навігацыю", "RAIL_DASHBOARD": "Панэль", "OPEN_DASHBOARD": "Адкрыць панэль", "RAIL_SOURCES": "Крыніцы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4bb53182e..e26645d52 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Navigation ausklappen", + "COLLAPSE_NAVIGATION": "Navigation einklappen", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Dashboard öffnen", "RAIL_SOURCES": "Quellen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 70b2795e3..530692f04 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Ανάπτυξη πλοήγησης", + "COLLAPSE_NAVIGATION": "Σύμπτυξη πλοήγησης", "RAIL_DASHBOARD": "Πίνακας ελέγχου", "OPEN_DASHBOARD": "Άνοιγμα πίνακα ελέγχου", "RAIL_SOURCES": "Πηγές", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b77a49418..0a6f02f85 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Expand navigation", + "COLLAPSE_NAVIGATION": "Collapse navigation", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Sources", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0c4087ff0..ea6466052 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Ampliar navegación", + "COLLAPSE_NAVIGATION": "Contraer navegación", "RAIL_DASHBOARD": "Panel", "OPEN_DASHBOARD": "Abrir panel", "RAIL_SOURCES": "Fuentes", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 30be779d4..2ff304679 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Développer la navigation", + "COLLAPSE_NAVIGATION": "Réduire la navigation", "RAIL_DASHBOARD": "Tableau de bord", "OPEN_DASHBOARD": "Ouvrir le tableau de bord", "RAIL_SOURCES": "Sources", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e5f8559a4..e72ae23c3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Espandi navigazione", + "COLLAPSE_NAVIGATION": "Comprimi navigazione", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Apri dashboard", "RAIL_SOURCES": "Sorgenti", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 9be5c7845..c13528872 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "ナビゲーションを展開", + "COLLAPSE_NAVIGATION": "ナビゲーションを折りたたむ", "RAIL_DASHBOARD": "ダッシュボード", "OPEN_DASHBOARD": "ダッシュボードを開く", "RAIL_SOURCES": "ソース", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 5b35c4905..469977fb9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "탐색 펼치기", + "COLLAPSE_NAVIGATION": "탐색 접기", "RAIL_DASHBOARD": "대시보드", "OPEN_DASHBOARD": "대시보드 열기", "RAIL_SOURCES": "소스", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 70e472cce..30c3e0626 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Navigatie uitklappen", + "COLLAPSE_NAVIGATION": "Navigatie inklappen", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Bronnen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 38fdaea82..d4787cc4a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Rozwiń nawigację", + "COLLAPSE_NAVIGATION": "Zwiń nawigację", "RAIL_DASHBOARD": "Pulpit", "OPEN_DASHBOARD": "Otwórz pulpit", "RAIL_SOURCES": "Źródła", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index a1b75be66..dafc94454 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Expandir navegação", + "COLLAPSE_NAVIGATION": "Recolher navegação", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Abrir dashboard", "RAIL_SOURCES": "Fontes", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f043a6b13..171182a76 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Развернуть навигацию", + "COLLAPSE_NAVIGATION": "Свернуть навигацию", "RAIL_DASHBOARD": "Панель", "OPEN_DASHBOARD": "Открыть панель", "RAIL_SOURCES": "Источники", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 577f4ac60..0fb12f3f3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Gezinmeyi genişlet", + "COLLAPSE_NAVIGATION": "Gezinmeyi daralt", "RAIL_DASHBOARD": "Pano", "OPEN_DASHBOARD": "Panoyu aç", "RAIL_SOURCES": "Kaynaklar", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e473b0886..b628b35e7 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "展开导航", + "COLLAPSE_NAVIGATION": "收起导航", "RAIL_DASHBOARD": "仪表盘", "OPEN_DASHBOARD": "打开仪表盘", "RAIL_SOURCES": "源", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index b138661e7..f1bde455c 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1046,6 +1046,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "展開導覽", + "COLLAPSE_NAVIGATION": "收合導覽", "RAIL_DASHBOARD": "儀表板", "OPEN_DASHBOARD": "開啟儀表板", "RAIL_SOURCES": "來源", diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index f5a7d14a8..8b37a2350 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -164,6 +164,26 @@ Rail navigation is also shell-owned: 3. On dashboard, sources, settings, global search, global favorites, and global recent, the shell falls back to the currently selected playlist so provider navigation remains available even outside a provider route. +4. The top disclosure button is not a route. It shows a downward triangle in + the default 60px icon rail and a right-pointing triangle when expanded, so + the Dashboard destination appears exactly once in the actual link list. +5. The expanded rail shows the IPTVnator logo/name plus translated labels for + every workspace, provider, and settings entry. Language direction controls + the rail's `dir` attribute; logical start alignment therefore follows LTR + and RTL translations without maintaining separate layouts. +6. Expanded width is content-driven with lower and upper bounds. Navigation + links own the scrollable area while Settings remains visible in the fixed + footer. +7. At the compact shell breakpoint the rail remains horizontal and icon-only; + direct attempts to set the expanded model are rejected as well as button + clicks. +8. The rail keeps the standard theme-token surface and selection styling. + Hovered entries use the same blue selection surface as the settings + navigation. Fine-pointer devices magnify only the hovered icon to 2.1, so a + 20px icon remains inside its 44px menu slot in both rail states. Neighboring + icons, labels, and the disclosure triangle are not transformed; + reduced-motion mode disables the remaining transform. Interactive controls + remain explicit Electron `no-drag` regions. Command palette behavior is shell-owned but view-extensible: diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html index 5642a1b9b..f78de8464 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html @@ -1,4 +1,4 @@ -