App rail, pill counts, tinted folder icons and raised controls
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m4s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m4s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
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).
This commit is contained in:
1 parent
3434a5ed39
commit
5fe929c6a4
15 files changed
+248
-12
No files matched your search
@@ -732,6 +732,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Einstellungen",
|
"Settings": "Einstellungen",
|
||||||
|
"Show folder list": "Ordnerliste einblenden",
|
||||||
|
"Hide folder list": "Ordnerliste ausblenden",
|
||||||
"All settings": "Alle Einstellungen",
|
"All settings": "Alle Einstellungen",
|
||||||
"Sections": "Bereiche",
|
"Sections": "Bereiche",
|
||||||
"General": "Allgemein",
|
"General": "Allgemein",
|
||||||
|
|||||||
@@ -727,6 +727,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Configuración",
|
"Settings": "Configuración",
|
||||||
|
"Show folder list": "Mostrar lista de carpetas",
|
||||||
|
"Hide folder list": "Ocultar lista de carpetas",
|
||||||
"All settings": "Toda la configuración",
|
"All settings": "Toda la configuración",
|
||||||
"Sections": "Secciones",
|
"Sections": "Secciones",
|
||||||
"General": "General",
|
"General": "General",
|
||||||
|
|||||||
@@ -733,6 +733,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Paramètres",
|
"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",
|
"All settings": "Tous les paramètres",
|
||||||
"Sections": "Sections",
|
"Sections": "Sections",
|
||||||
"General": "Général",
|
"General": "Général",
|
||||||
|
|||||||
@@ -727,6 +727,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "設定",
|
"Settings": "設定",
|
||||||
|
"Show folder list": "フォルダー一覧を表示",
|
||||||
|
"Hide folder list": "フォルダー一覧を隠す",
|
||||||
"All settings": "すべての設定",
|
"All settings": "すべての設定",
|
||||||
"Sections": "セクション",
|
"Sections": "セクション",
|
||||||
"General": "一般",
|
"General": "一般",
|
||||||
|
|||||||
@@ -728,6 +728,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Instellingen",
|
"Settings": "Instellingen",
|
||||||
|
"Show folder list": "Mappenlijst tonen",
|
||||||
|
"Hide folder list": "Mappenlijst verbergen",
|
||||||
"All settings": "Alle instellingen",
|
"All settings": "Alle instellingen",
|
||||||
"Sections": "Onderdelen",
|
"Sections": "Onderdelen",
|
||||||
"General": "Algemeen",
|
"General": "Algemeen",
|
||||||
|
|||||||
@@ -730,6 +730,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Configurações",
|
"Settings": "Configurações",
|
||||||
|
"Show folder list": "Mostrar lista de pastas",
|
||||||
|
"Hide folder list": "Ocultar lista de pastas",
|
||||||
"All settings": "Todas as configurações",
|
"All settings": "Todas as configurações",
|
||||||
"Sections": "Seções",
|
"Sections": "Seções",
|
||||||
"General": "Geral",
|
"General": "Geral",
|
||||||
|
|||||||
@@ -730,6 +730,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Настройки",
|
"Settings": "Настройки",
|
||||||
|
"Show folder list": "Показать список папок",
|
||||||
|
"Hide folder list": "Скрыть список папок",
|
||||||
"All settings": "Все настройки",
|
"All settings": "Все настройки",
|
||||||
"Sections": "Разделы",
|
"Sections": "Разделы",
|
||||||
"General": "Общие",
|
"General": "Общие",
|
||||||
|
|||||||
@@ -1090,6 +1090,8 @@ export const catalog: Catalog = {
|
|||||||
"Set on this role": "Bu role ayarlandı",
|
"Set on this role": "Bu role ayarlandı",
|
||||||
"Set password": "Şifre belirle",
|
"Set password": "Şifre belirle",
|
||||||
"Settings": "Ayarlar",
|
"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 imported": "Ayarlar içe aktarıldı",
|
||||||
"Settings reset to defaults": "Ayarlar varsayılanlara sıfırlandı",
|
"Settings reset to defaults": "Ayarlar varsayılanlara sıfırlandı",
|
||||||
"Settings → Filters & rules": "Ayarlar → Filtreler ve kurallar",
|
"Settings → Filters & rules": "Ayarlar → Filtreler ve kurallar",
|
||||||
|
|||||||
@@ -724,6 +724,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Налаштування",
|
"Settings": "Налаштування",
|
||||||
|
"Show folder list": "Показати список тек",
|
||||||
|
"Hide folder list": "Сховати список тек",
|
||||||
"All settings": "Усі налаштування",
|
"All settings": "Усі налаштування",
|
||||||
"Sections": "Розділи",
|
"Sections": "Розділи",
|
||||||
"General": "Загальні",
|
"General": "Загальні",
|
||||||
|
|||||||
@@ -726,6 +726,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "设置",
|
"Settings": "设置",
|
||||||
|
"Show folder list": "显示文件夹列表",
|
||||||
|
"Hide folder list": "隐藏文件夹列表",
|
||||||
"All settings": "全部设置",
|
"All settings": "全部设置",
|
||||||
"Sections": "分区",
|
"Sections": "分区",
|
||||||
"General": "常规",
|
"General": "常规",
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+77
-1
@@ -1316,7 +1316,7 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.collapsed .compose-btn span { display: none; }
|
.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 { 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; }
|
.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 { 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:hover { background: var(--bg-hover); }
|
||||||
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
|
.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); }
|
.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; }
|
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||||
/* Outlook-style module bar at the bottom of the sidebar */
|
/* 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-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 { 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); }
|
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||||
|
|||||||
+47
-10
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
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 { useSession } from "@/store/session";
|
||||||
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
||||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
@@ -52,6 +52,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
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 sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
/*
|
/*
|
||||||
@@ -166,9 +167,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<div className={`app ${viewing ? "delegated" : ""}`}>
|
<div className={`app ${viewing ? "delegated" : ""}`}>
|
||||||
<DelegatedBar />
|
<DelegatedBar />
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
|
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */}
|
||||||
<MenuIcon size={22} />
|
{isMobile && (
|
||||||
</button>
|
<button className="icon-btn" aria-label={t("Menu")} onClick={() => setDrawer((d) => !d)}>
|
||||||
|
<MenuIcon size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
||||||
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||||
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||||
@@ -193,9 +197,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<HelpCircle size={21} />
|
<HelpCircle size={21} />
|
||||||
</button>
|
</button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
{/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */}
|
||||||
<Settings size={21} />
|
{isMobile && (
|
||||||
</Link>
|
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||||
|
<Settings size={21} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
||||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||||
</button>
|
</button>
|
||||||
@@ -263,10 +270,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
className={`app-body ${!isMobile ? "has-rail" : ""} ${collapsed && !isMobile ? "collapsed" : ""} ${collapsed && !isMobile && section !== "mail" && section !== "search" ? "sidebar-hidden" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||||
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
||||||
>
|
>
|
||||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||||
|
{!isMobile && (
|
||||||
|
<nav className="app-rail" aria-label={t("Go to")}>
|
||||||
|
<RailLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} badge={inboxUnread} />
|
||||||
|
<RailLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||||
|
<RailLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||||
|
<RailLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||||
|
<span className="app-rail-spacer" />
|
||||||
|
<RailLink href="/settings" icon={<Settings size={20} />} label={t("Settings")} active={section === "settings"} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rail-link rail-toggle"
|
||||||
|
aria-label={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||||
|
title={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
onClick={() => update({ sidebarCollapsed: !collapsed })}
|
||||||
|
>
|
||||||
|
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
||||||
{/*
|
{/*
|
||||||
The way back out.
|
The way back out.
|
||||||
@@ -316,12 +343,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{section === "admin" && <AdminNav />}
|
{section === "admin" && <AdminNav />}
|
||||||
</div>
|
</div>
|
||||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||||
<nav className="module-bar" aria-label={t("Go to")}>
|
{isMobile && <nav className="module-bar" aria-label={t("Go to")}>
|
||||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
||||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||||
</nav>
|
</nav>}
|
||||||
</aside>
|
</aside>
|
||||||
{/* Not on a phone, where the sidebar is a drawer over the page, and not
|
{/* 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. */}
|
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 (
|
||||||
|
<Link href={href} className={`rail-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||||
|
{icon}
|
||||||
|
{badge > 0 && <span className="rail-badge">{badge > 999 ? "999+" : badge}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
/** 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 }) {
|
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
>
|
>
|
||||||
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
<span className="folder-icon" data-role={m.role ?? (scheduled ? "scheduled" : undefined)} style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||||
<span className="nav-label">{label}</span>
|
<span className="nav-label">{label}</span>
|
||||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||||
{count > 0 && <span className="nav-dot" />}
|
{count > 0 && <span className="nav-dot" />}
|
||||||
|
|||||||
@@ -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(<MailboxTree />));
|
||||||
|
});
|
||||||
|
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
||||||
|
|
||||||
|
const icons = () => Array.from(document.querySelectorAll<HTMLElement>(".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"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user