From 5fe929c6a42877a8cdebb30d4c362db6431b550e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 12:01:27 -0700 Subject: [PATCH] App rail, pill counts, tinted folder icons and raised controls On a wide screen the switcher moves from the foot of the folder pane to a rail down the left edge: Mail (with the inbox's unread badge), Calendar, Contacts and Files, then Settings and the folder-list toggle at the bottom. The top bar loses the menu button and the Settings gear there, both now on the rail. Phones are unchanged: no rail, the tab bar, the menu button for the drawer and the gear. - Folder counts are pills: filled with the accent when there is unread mail, neutral for totals (Drafts, Scheduled). - Role folders get their own icon tint (Inbox, Drafts, Sent, Archive, Junk, Trash, Scheduled); a color the reader picked still wins. - Buttons, Compose and the current selection have a top-lit, raised look with a pressed state; Compose and the selected rail item are filled with the accent. - Collapsing the sidebar keeps Mail's icon strip; Calendar, Contacts, Files, Settings and Admin have no icon-only form, so their sidebar hides instead of being crushed. - The collapsed sidebar no longer draws a cut-off "FOLDERS": the rule hiding section headings lost to a later one of the same weight (this is on main today too). Contrast: the accent behind small text is darkened in light themes and lightened a touch in dark ones. Measured in a browser across all 12 palettes x 6 accents x both modes: text on pills, the rail badge and Compose at least 4.83:1, tinted icons at least 3.14:1 on the sidebar. New strings: 2 ("Show folder list", "Hide folder list"), in all ten catalogs (1699 -> 1701). --- web/src/locales/de.ts | 2 + web/src/locales/es.ts | 2 + web/src/locales/fr.ts | 2 + web/src/locales/ja.ts | 2 + web/src/locales/nl.ts | 2 + web/src/locales/pt-BR.ts | 2 + web/src/locales/ru.ts | 2 + web/src/locales/tr.ts | 2 + web/src/locales/uk.ts | 2 + web/src/locales/zh-Hans.ts | 2 + web/src/styles/__tests__/app-rail.test.ts | 49 ++++++++++++ web/src/styles/app.css | 78 ++++++++++++++++++- web/src/views/AppShell.tsx | 57 +++++++++++--- web/src/views/mail/MailboxTree.tsx | 2 +- .../mail/__tests__/folder-role-tint.test.tsx | 54 +++++++++++++ 15 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 web/src/styles/__tests__/app-rail.test.ts create mode 100644 web/src/views/mail/__tests__/folder-role-tint.test.tsx diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index a64efaa..3d3e248 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -732,6 +732,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Einstellungen", + "Show folder list": "Ordnerliste einblenden", + "Hide folder list": "Ordnerliste ausblenden", "All settings": "Alle Einstellungen", "Sections": "Bereiche", "General": "Allgemein", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 3bd5cbe..e838c57 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -727,6 +727,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Configuración", + "Show folder list": "Mostrar lista de carpetas", + "Hide folder list": "Ocultar lista de carpetas", "All settings": "Toda la configuración", "Sections": "Secciones", "General": "General", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index c5da778..78c4e22 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -733,6 +733,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Paramètres", + "Show folder list": "Afficher la liste des dossiers", + "Hide folder list": "Masquer la liste des dossiers", "All settings": "Tous les paramètres", "Sections": "Sections", "General": "Général", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 204cba9..257a58e 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -727,6 +727,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "設定", + "Show folder list": "フォルダー一覧を表示", + "Hide folder list": "フォルダー一覧を隠す", "All settings": "すべての設定", "Sections": "セクション", "General": "一般", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 87e3534..019152e 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -728,6 +728,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Instellingen", + "Show folder list": "Mappenlijst tonen", + "Hide folder list": "Mappenlijst verbergen", "All settings": "Alle instellingen", "Sections": "Onderdelen", "General": "Algemeen", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index ff7869d..f0b7af6 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -730,6 +730,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Configurações", + "Show folder list": "Mostrar lista de pastas", + "Hide folder list": "Ocultar lista de pastas", "All settings": "Todas as configurações", "Sections": "Seções", "General": "Geral", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index b3482c9..9771db2 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -730,6 +730,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Настройки", + "Show folder list": "Показать список папок", + "Hide folder list": "Скрыть список папок", "All settings": "Все настройки", "Sections": "Разделы", "General": "Общие", diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index ec48d48..34256b1 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1090,6 +1090,8 @@ export const catalog: Catalog = { "Set on this role": "Bu role ayarlandı", "Set password": "Şifre belirle", "Settings": "Ayarlar", + "Show folder list": "Klasör listesini göster", + "Hide folder list": "Klasör listesini gizle", "Settings imported": "Ayarlar içe aktarıldı", "Settings reset to defaults": "Ayarlar varsayılanlara sıfırlandı", "Settings → Filters & rules": "Ayarlar → Filtreler ve kurallar", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index b9aa752..b50ef44 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -724,6 +724,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "Налаштування", + "Show folder list": "Показати список тек", + "Hide folder list": "Сховати список тек", "All settings": "Усі налаштування", "Sections": "Розділи", "General": "Загальні", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 9ee4592..a633fc1 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -726,6 +726,8 @@ export const catalog: Catalog = { // ── Settings ─────────────────────────────────────────────────────── "Settings": "设置", + "Show folder list": "显示文件夹列表", + "Hide folder list": "隐藏文件夹列表", "All settings": "全部设置", "Sections": "分区", "General": "常规", diff --git a/web/src/styles/__tests__/app-rail.test.ts b/web/src/styles/__tests__/app-rail.test.ts new file mode 100644 index 0000000..891c79b --- /dev/null +++ b/web/src/styles/__tests__/app-rail.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// Read off disk: `?raw` comes back empty for a stylesheet under the test runner. +const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../app.css"), "utf8"); + +/* + * The app rail, the collapsed sidebar and the raised surfaces are all CSS, and + * jsdom does no layout, so these pin the rules themselves. Contrast for the + * pill accent was measured in a browser across every palette, accent and + * mode; the shape of that rule is pinned here. + */ + +const rule = (selector: string) => { + const i = css.indexOf(selector + " {"); + expect(i, `no rule for ${selector}`).toBeGreaterThan(-1); + return css.slice(i, css.indexOf("}", i)); +}; + +describe("app layout CSS", () => { + it("gives the rail its own column ahead of the sidebar, collapsed or not", () => { + expect(rule(".app-body.has-rail")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w\)/); + expect(rule(".app-body.has-rail.collapsed")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w-collapsed\)/); + }); + + it("hides a sidebar with no icon-only form instead of crushing it", () => { + expect(rule(".app-body.has-rail.sidebar-hidden")).toMatch(/grid-template-columns:\s*56px 0 /); + expect(rule(".app-body.sidebar-hidden > .sidebar")).toMatch(/visibility:\s*hidden/); + }); + + it("hides section headings when collapsed, over the sidebar's own display rule", () => { + // `.sidebar .nav-section { display: flex }` comes later at the same weight; + // the collapsed rule has to outweigh it or "FOLDERS" is drawn cut off. + expect(css).toMatch(/\.app-body\.collapsed \.sidebar \.nav-section[^{]*\{\s*display:\s*none/); + }); + + it("darkens the pill accent in light themes and lightens it in dark ones", () => { + expect(css).toMatch(/:root \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, black\); \}/); + expect(css).toMatch(/:root\[data-theme="dark"\] \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, white\); \}/); + }); + + it("lets a color the reader picked win over the role tint", () => { + for (const role of ["inbox", "drafts", "sent", "archive", "junk", "trash", "scheduled"]) { + expect(css).toContain(`.folder-icon[data-role="${role}"]:not([style*="--folder-color"]) svg`); + } + }); +}); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index eeab1a0..609de75 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1316,7 +1316,7 @@ a.menu-item:hover { color: var(--fg); } .collapsed .compose-btn span { display: none; } .nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); } .nav-section .icon-btn { width: 24px; height: 24px; } -.collapsed .nav-section { display: none; } +.collapsed .nav-section, .app-body.collapsed .sidebar .nav-section { display: none; } .nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; } .nav-item:hover { background: var(--bg-hover); } .nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); } @@ -1383,6 +1383,82 @@ a.menu-item:hover { color: var(--fg); } .collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; } /* Outlook-style module bar at the bottom of the sidebar */ +/* App rail. A fixed column left of the sidebar on wide screens; phones keep the tab bar. */ +.app-body.has-rail { grid-template-columns: 56px var(--sidebar-w) minmax(0, 1fr); } +.app-body.has-rail.collapsed { grid-template-columns: 56px var(--sidebar-w-collapsed) minmax(0, 1fr); } +.app-body.has-rail > .sidebar-splitter { left: calc(56px + var(--sidebar-w) - 3px); } +/* Only Mail's folder list has an icon-only form. Calendar, Contacts, Files, Settings and Admin + hide their sidebar outright when collapsed; the rail still navigates and its toggle brings it back. */ +.app-body.has-rail.sidebar-hidden { grid-template-columns: 56px 0 minmax(0, 1fr); } +.app-body.sidebar-hidden > .sidebar { visibility: hidden; padding: 0; min-width: 0; } +.app-rail { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 12px; border-right: 1px solid var(--border); min-height: 0; } +.app-rail-spacer { flex: 1; } +.rail-toggle { background: none; border: 0; cursor: pointer; margin-top: 4px; } +.rail-link { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 12px; color: var(--fg-muted); transition: background .12s, color .12s; } +.rail-link:hover { background: var(--bg-hover); color: var(--fg); } +.rail-link.active { background: var(--accent-soft); color: var(--accent-soft-fg); } +.rail-badge { position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--pill-accent); color: var(--accent-fg); font-size: 10.5px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--bg); } + +/* Pill counts. Accent-filled when the folder has unread mail, neutral for totals (Drafts, Scheduled). */ +.nav-item .nav-count { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--fg-muted); font-size: .74em; font-weight: 650; font-variant-numeric: tabular-nums; } +/* The accent as a fill behind small text (pills, the rail badge, Compose). The plain accent is + a mid tone that white text does not read on in light themes, so it is darkened there and + lightened a touch in dark ones: at least 4.5:1 in all 12 palettes x 6 accents x 2 modes, + measured 2026-10-05 (lowest 4.83). */ +:root { --pill-accent: color-mix(in srgb, var(--accent) 70%, black); } +:root[data-theme="dark"] { --pill-accent: color-mix(in srgb, var(--accent) 88%, white); } +.nav-item.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); font-weight: 700; } +.nav-item.active .nav-count, .nav-item.active.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .25); } +.collapsed .nav-item .nav-count { display: none; } + +/* Raised surfaces. A top-lit sheen, a 1px inner highlight on top and shade below, and a small drop, + so buttons and the current selection read as pieces standing off the page. Tuned per theme lightness. */ +:root { + --raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .04) 50%, rgb(0 0 0 / .06)); + --raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .32), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .14)); + --raise-hi: rgb(255 255 255 / .75); + --raise-lo: rgb(0 0 0 / .12); + --raise-drop: 0 1px 2px rgb(0 0 0 / .14), 0 3px 8px rgb(0 0 0 / .09); + --raise-press: inset 0 1px 2px rgb(0 0 0 / .14); +} +:root[data-theme="dark"] { + --raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .14), rgb(255 255 255 / .02) 50%, rgb(0 0 0 / .16)); + --raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .22)); + --raise-hi: rgb(255 255 255 / .16); + --raise-lo: rgb(0 0 0 / .45); + --raise-drop: 0 1px 2px rgb(0 0 0 / .55), 0 3px 10px rgb(0 0 0 / .32); + --raise-press: inset 0 1px 3px rgb(0 0 0 / .45); +} +.btn, .compose-btn, .rail-link.active, .nav-item.active, .module-link.active, .segmented button.active { + background-image: var(--raise-sheen); + box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo), var(--raise-drop); +} +.btn-primary, .rail-badge { background-image: var(--raise-sheen-strong); } +.compose-btn, .compose-btn:hover { background-color: var(--pill-accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); } +.compose-btn svg { color: inherit; } +.compose-btn, .rail-link.active { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--raise-drop); } +.compose-btn:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--shadow-2); filter: brightness(1.05); } +.rail-link.active, .rail-link.active:hover { background-color: var(--accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); } +.btn-primary { box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), inset 0 -1px 0 rgb(0 0 0 / .18), var(--raise-drop); } +.btn:active, .compose-btn:active, .rail-link:active, .nav-item:active, .module-link:active, .segmented button:active { + transform: translateY(1px); + box-shadow: var(--raise-press); + background-image: none; +} +/* The segmented control clips its children; the raise sits inside its border. */ +.segmented button.active { box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo); } + +/* Tinted role-folder icons. A color the reader picked for a folder still wins (style attribute, fill rule above). */ +:root { --role-inbox: #2f6fe4; --role-drafts: #8a5cd6; --role-sent: #138a6c; --role-archive: #976214; --role-junk: #b8501c; --role-trash: #c93c4b; --role-scheduled: #0f8aa6; } +:root[data-theme="dark"] { --role-inbox: #6ea2ff; --role-drafts: #b796f5; --role-sent: #4fcfa6; --role-archive: #e5b45a; --role-junk: #f2925f; --role-trash: #f07a87; --role-scheduled: #4fc3dc; } +.folder-row .folder-icon[data-role="inbox"]:not([style*="--folder-color"]) svg { color: var(--role-inbox); } +.folder-row .folder-icon[data-role="drafts"]:not([style*="--folder-color"]) svg { color: var(--role-drafts); } +.folder-row .folder-icon[data-role="sent"]:not([style*="--folder-color"]) svg { color: var(--role-sent); } +.folder-row .folder-icon[data-role="archive"]:not([style*="--folder-color"]) svg { color: var(--role-archive); } +.folder-row .folder-icon[data-role="junk"]:not([style*="--folder-color"]) svg { color: var(--role-junk); } +.folder-row .folder-icon[data-role="trash"]:not([style*="--folder-color"]) svg { color: var(--role-trash); } +.folder-row .folder-icon[data-role="scheduled"]:not([style*="--folder-color"]) svg { color: var(--role-scheduled); } + .module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; } .module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; } .module-link:hover { background: var(--bg-hover); color: var(--fg); } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 59e2de0..f96d98b 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; +import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; @@ -52,6 +52,7 @@ export function AppShell({ children }: { children: ReactNode }) { const [location, navigate] = useLocation(); const isMobile = useIsMobile(); const collapsed = useSettings((s) => s.settings.sidebarCollapsed); + const inboxUnread = useMail((s) => Object.values(s.mailboxes).find((m) => m.role === "inbox")?.unreadEmails ?? 0); const sidebarWidth = useSettings((s) => s.settings.sidebarWidth); const update = useSettings((s) => s.update); /* @@ -166,9 +167,12 @@ export function AppShell({ children }: { children: ReactNode }) {
- + {/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */} + {isMobile && ( + + )} {viewing && } @@ -193,9 +197,12 @@ export function AppShell({ children }: { children: ReactNode }) { - - - + {/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */} + {isMobile && ( + + + + )} @@ -263,10 +270,30 @@ export function AppShell({ children }: { children: ReactNode }) {
setDrawer(false)} /> + {!isMobile && ( + + )}
{(section === "mail" || section === "search") && } - } {/* Not on a phone, where the sidebar is a drawer over the page, and not while collapsed to icons, where there is no width to choose. */} @@ -406,6 +433,16 @@ export function AppShell({ children }: { children: ReactNode }) { ); } +/** The app rail down the left edge, which replaces the module bar on a wide screen. */ +function RailLink({ href, icon, label, active, badge = 0 }: { href: string; icon: ReactNode; label: string; active: boolean; badge?: number }) { + return ( + + {icon} + {badge > 0 && {badge > 999 ? "999+" : badge}} + + ); +} + /** Outlook-style module switcher at the bottom of the folder pane. */ function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) { return ( diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 900870f..3835f12 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -438,7 +438,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, > {twisty ? open ? : : null} - {icon} + {icon} {label} {count > 0 && {count > 9999 ? "9999+" : count}} {count > 0 && } diff --git a/web/src/views/mail/__tests__/folder-role-tint.test.tsx b/web/src/views/mail/__tests__/folder-role-tint.test.tsx new file mode 100644 index 0000000..0c891cb --- /dev/null +++ b/web/src/views/mail/__tests__/folder-role-tint.test.tsx @@ -0,0 +1,54 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MailboxTree } from "../MailboxTree"; +import { useMail } from "@/store/mail"; +import { useSettings } from "@/store/settings"; +import type { Mailbox, MailboxRole } from "@/jmap/types"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia; + +/* + * Role folders get their own icon tint, read from the icon's data-role; a + * plain folder has none, and a color the reader chose still applies. + */ + +const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true }; +const box = (id: string, name: string, role: MailboxRole, unread = 0): Mailbox => ({ + id, name, parentId: null, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true, +}); + +describe("folder icons by role", () => { + let host: HTMLDivElement; + let root: Root; + beforeEach(() => { + window.history.replaceState({}, "", "/mail/inbox"); + useMail.setState({ + mailboxes: { inbox: box("inbox", "Inbox", "inbox", 3), junk: box("junk", "Junk", "junk", 1), work: box("work", "Work", null, 2) }, + mailboxesLoaded: true, + }); + useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false, folderColors: { junk: "#123456" } } })); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + const icons = () => Array.from(document.querySelectorAll(".nav-item.folder-row .folder-icon")); + const byRole = (role: string) => icons().find((i) => i.dataset.role === role)!; + + it("marks role folders with their role and leaves a plain folder unmarked", () => { + expect(icons().map((i) => i.dataset.role ?? null).sort()).toEqual(["inbox", "junk", null].sort()); + }); + + it("keeps the reader's own color on a role folder", () => { + expect(byRole("junk").style.getPropertyValue("--folder-color")).toBe("#123456"); + }); + + it("shows unread counts as pills", () => { + const pill = Array.from(document.querySelectorAll(".nav-item.folder-row.unread .nav-count")).map((p) => p.textContent); + expect(pill).toEqual(expect.arrayContaining(["3", "1", "2"])); + }); +}); -- 2.54.0