diff --git a/src/i18n/en.json b/src/i18n/en.json
index b5dd2fd..bbdf247 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -61,7 +61,8 @@
"preset7d": "Last 7 days",
"preset90d": "Last 90 days",
"title": "Dashboard",
- "to": "To"
+ "to": "To",
+ "liveUnavailable": "Live numbers aren't available on this server yet. The rest of the dashboard still works."
},
"deliveryTrace": {
"attemptCount_one": "{{count}} attempt",
@@ -282,7 +283,8 @@
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
"showingItems": "Showing {{count}} items",
"sort": "Sort",
- "unknownError": "Unknown error"
+ "unknownError": "Unknown error",
+ "emptyTitle": "Nothing here yet"
},
"login": {
"continue": "Continue",
@@ -382,5 +384,11 @@
"source": {
"download": "Source code of this version ({{id}}), AGPL-3.0",
"menu": "Source code (AGPL-3.0)"
+ },
+ "greeting": {
+ "morning": "Good morning",
+ "afternoon": "Good afternoon",
+ "evening": "Good evening",
+ "subtitle": "Here's how your mail server is doing."
}
}
diff --git a/src/index.css b/src/index.css
index 846e2c9..a7945b4 100644
--- a/src/index.css
+++ b/src/index.css
@@ -8,77 +8,89 @@
@import 'tailwindcss';
:root {
- --background: #ffffff;
- --foreground: #111827;
+ /* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */
+ --background: #fbfaf7;
+ --foreground: #16262f;
--card: #ffffff;
- --card-foreground: #111827;
+ --card-foreground: #16262f;
--popover: #ffffff;
- --popover-foreground: #111827;
+ --popover-foreground: #16262f;
- --content-background: #f6f8fa;
+ --content-background: #f4f1ea;
- --primary: #0f766e;
+ --primary: #0d8a82;
--primary-foreground: #ffffff;
- --secondary: #eef1f4;
- --secondary-foreground: #111827;
+ --secondary: #efece5;
+ --secondary-foreground: #16262f;
- --muted: #eef1f4;
- --muted-foreground: #5b6472;
+ --muted: #efece5;
+ --muted-foreground: #5f6b73;
- --accent: #d9f1ee;
- --accent-foreground: #0b5750;
+ --accent: #e1f4f1;
+ --accent-foreground: #0a5f59;
- --destructive: #dc2626;
+ /* The cat's orange, for the few things that should feel warm. */
+ --highlight: #f59e3f;
+ --highlight-soft: #fdebd5;
+
+ --destructive: #d9383a;
--destructive-foreground: #ffffff;
- --border: #e3e7ec;
- --input: #e3e7ec;
- --ring: #0f766e;
+ --border: #e7e2d8;
+ --input: #ddd7cb;
+ --ring: #0d8a82;
- --radius: 0.5rem;
+ --radius: 0.75rem;
+ --shadow-soft: 0 1px 2px rgb(22 38 47 / 0.04), 0 4px 16px rgb(22 38 47 / 0.05);
- --chart-1: #0f766e;
- --chart-2: #f9a34c;
+ --chart-1: #0d8a82;
+ --chart-2: #f59e3f;
--chart-3: #1c4053;
- --chart-4: #0e7490;
+ --chart-4: #46cac3;
--chart-5: #b45309;
}
.dark {
- --background: #111a2b;
- --foreground: #e5e9f0;
+ /* INBUXA, dark: the lockup's own background, lifted surfaces, bright teal. */
+ --background: #0e1c26;
+ --foreground: #e7eef2;
- --card: #111a2b;
- --card-foreground: #e5e9f0;
+ --card: #132633;
+ --card-foreground: #e7eef2;
- --popover: #111a2b;
- --popover-foreground: #e5e9f0;
+ --popover: #152a38;
+ --popover-foreground: #e7eef2;
- --content-background: #0b1220;
+ --content-background: #0b1720;
- --primary: #2dd4bf;
- --primary-foreground: #052e2b;
+ --primary: #46cac3;
+ --primary-foreground: #062a28;
- --secondary: #1f2a3d;
- --secondary-foreground: #e5e9f0;
+ --secondary: #1a3140;
+ --secondary-foreground: #e7eef2;
- --muted: #1f2a3d;
- --muted-foreground: #9aa5b8;
+ --muted: #1a3140;
+ --muted-foreground: #93a8b6;
- --accent: #12343a;
- --accent-foreground: #99f6e4;
+ --accent: #133d40;
+ --accent-foreground: #9ff0e9;
+
+ --highlight: #f9a34c;
+ --highlight-soft: #3a2a18;
--destructive: #f87171;
- --destructive-foreground: #0b1220;
+ --destructive-foreground: #0b1720;
- --border: #1f2a3d;
- --input: #1f2a3d;
- --ring: #2dd4bf;
+ --border: #1f3847;
+ --input: #274455;
+ --ring: #46cac3;
- --chart-1: #2dd4bf;
+ --shadow-soft: 0 1px 2px rgb(0 0 0 / 0.25), 0 6px 20px rgb(0 0 0 / 0.2);
+
+ --chart-1: #46cac3;
--chart-2: #f9a34c;
--chart-3: #9fc2d6;
--chart-4: #67e8f9;
@@ -108,6 +120,9 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
+ --color-highlight: var(--highlight);
+ --color-highlight-soft: var(--highlight-soft);
+
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
@@ -115,16 +130,20 @@
--color-input: var(--input);
--color-ring: var(--ring);
- --color-chart-1: hsl(var(--chart-1));
- --color-chart-2: hsl(var(--chart-2));
- --color-chart-3: hsl(var(--chart-3));
- --color-chart-4: hsl(var(--chart-4));
- --color-chart-5: hsl(var(--chart-5));
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
+
+ --font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
+ --font-display: 'Space Grotesk Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
+ --shadow-soft: var(--shadow-soft);
}
@layer base {
@@ -133,7 +152,24 @@
}
body {
- @apply bg-background text-foreground;
+ @apply bg-background text-foreground antialiased;
+ font-feature-settings: 'cv11', 'ss01';
+ }
+
+ h1,
+ h2,
+ h3 {
+ font-family: var(--font-display);
+ letter-spacing: -0.015em;
+ }
+
+ ::selection {
+ background: color-mix(in oklab, var(--primary) 30%, transparent);
+ }
+
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
}
}
diff --git a/src/lib/humanize.test.ts b/src/lib/humanize.test.ts
new file mode 100644
index 0000000..ededbcd
--- /dev/null
+++ b/src/lib/humanize.test.ts
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, it } from 'vitest';
+import { humanize } from './humanize';
+
+describe('humanize', () => {
+ it('spells out property names', () => {
+ expect(humanize('defaultCertificateId')).toBe('Default certificate ID');
+ expect(humanize('mailExchangers')).toBe('Mail exchangers');
+ expect(humanize('proxyTrustedNetworks')).toBe('Proxy trusted networks');
+ expect(humanize('maxConnections')).toBe('Max connections');
+ });
+ it('keeps acronyms', () => {
+ expect(humanize('useHttpsForSmtp')).toBe('Use HTTPS for SMTP');
+ expect(humanize('oauthClientId')).toBe('OAuth client ID');
+ expect(humanize('DNSServer')).toBe('DNS server');
+ });
+ it('names views', () => {
+ expect(humanize('x:SystemSettings')).toBe('System settings');
+ expect(humanize('x:Account/User')).toBe('User');
+ });
+});
diff --git a/src/lib/humanize.ts b/src/lib/humanize.ts
new file mode 100644
index 0000000..05bf25d
--- /dev/null
+++ b/src/lib/humanize.ts
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/** Words that stay in capitals, or in their own casing, when a name is spelled out. */
+const SPECIAL: Record
= {
+ id: 'ID',
+ ids: 'IDs',
+ url: 'URL',
+ urls: 'URLs',
+ uri: 'URI',
+ uris: 'URIs',
+ tls: 'TLS',
+ dns: 'DNS',
+ mx: 'MX',
+ ip: 'IP',
+ ips: 'IPs',
+ api: 'API',
+ http: 'HTTP',
+ https: 'HTTPS',
+ smtp: 'SMTP',
+ imap: 'IMAP',
+ pop3: 'POP3',
+ jmap: 'JMAP',
+ dkim: 'DKIM',
+ spf: 'SPF',
+ dmarc: 'DMARC',
+ arc: 'ARC',
+ acme: 'ACME',
+ ldap: 'LDAP',
+ sql: 'SQL',
+ ttl: 'TTL',
+ oauth: 'OAuth',
+ oidc: 'OIDC',
+ sni: 'SNI',
+ mta: 'MTA',
+ dav: 'DAV',
+ cal: 'Cal',
+ ai: 'AI',
+ llm: 'LLM',
+ otp: 'OTP',
+ totp: 'TOTP',
+ s3: 'S3',
+};
+
+/**
+ * `defaultCertificateId` → "Default certificate ID", `x:SystemSettings` →
+ * "System settings". For names the server gives no label of its own: a
+ * person reads words, not identifiers.
+ */
+export function humanize(name: string): string {
+ const bare = name.replace(/^x:/, '').split('/').pop() ?? name;
+ const words = bare
+ .replace(/[_-]+/g, ' ')
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+ return words
+ .map((w, i) => {
+ const special = SPECIAL[w.toLowerCase()];
+ if (special) return special;
+ const lower = w.toLowerCase();
+ return i === 0 ? lower[0].toUpperCase() + lower.slice(1) : lower;
+ })
+ .join(' ');
+}
diff --git a/src/lib/iconTones.ts b/src/lib/iconTones.ts
new file mode 100644
index 0000000..b7d1654
--- /dev/null
+++ b/src/lib/iconTones.ts
@@ -0,0 +1,72 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type Tone = 'teal' | 'orange' | 'sky' | 'violet' | 'rose' | 'amber' | 'emerald' | 'indigo' | 'slate';
+
+/**
+ * Colors by meaning, for the icons the server's layout names:
+ * - teal: mail itself;
+ * - rose: security;
+ * - amber: storage and data;
+ * - sky: network and connectivity;
+ * - violet: people and identity;
+ * - indigo: monitoring and reports;
+ * - emerald: automation and tasks;
+ * - orange: look and feel;
+ * - slate: the rest.
+ */
+const TONES: Record = {
+ 'layout-dashboard': 'teal',
+ mail: 'teal',
+ inbox: 'teal',
+ send: 'teal',
+ 'mail-minus': 'teal',
+ plane: 'teal',
+ route: 'sky',
+ globe: 'sky',
+ cable: 'sky',
+ zap: 'sky',
+ monitor: 'sky',
+ 'shield-check': 'rose',
+ 'shield-alert': 'rose',
+ lock: 'rose',
+ fingerprint: 'rose',
+ 'key-round': 'rose',
+ 'key-square': 'rose',
+ filter: 'rose',
+ database: 'amber',
+ archive: 'amber',
+ boxes: 'amber',
+ folder: 'amber',
+ search: 'amber',
+ 'search-code': 'amber',
+ users: 'violet',
+ 'circle-user': 'violet',
+ contact: 'violet',
+ calendar: 'violet',
+ activity: 'indigo',
+ 'chart-line': 'indigo',
+ 'file-text': 'indigo',
+ 'list-checks': 'emerald',
+ clock: 'emerald',
+ brain: 'emerald',
+ 'file-code': 'emerald',
+ palette: 'orange',
+ 'app-window': 'orange',
+ settings: 'slate',
+ 'sliders-horizontal': 'slate',
+};
+
+const FALLBACK: Tone[] = ['teal', 'sky', 'violet', 'amber', 'emerald', 'indigo', 'orange', 'rose'];
+
+/** The tone for an icon name: by meaning where known, otherwise a stable pick from its name. */
+export function toneFor(name: string): Tone {
+ const known = TONES[name];
+ if (known) return known;
+ let h = 0;
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
+ return FALLBACK[h % FALLBACK.length];
+}
diff --git a/src/lib/viewIcon.ts b/src/lib/viewIcon.ts
new file mode 100644
index 0000000..8e90de2
--- /dev/null
+++ b/src/lib/viewIcon.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { LayoutSubItem, Schema } from '@/types/schema';
+
+function subtreeHas(items: LayoutSubItem[], viewName: string): boolean {
+ return items.some((it) => (it.type === 'link' ? it.viewName === viewName : subtreeHas(it.items, viewName)));
+}
+
+/** The icon of the sidebar entry a view sits under, so its page wears the same tile. */
+export function iconForView(schema: Schema | null, viewName: string): string | null {
+ if (!schema) return null;
+ for (const layout of schema.layouts ?? []) {
+ for (const item of layout.items) {
+ if ('link' in item && item.link.viewName === viewName) return item.link.icon;
+ if ('container' in item && subtreeHas(item.container.items, viewName)) return item.container.icon;
+ }
+ }
+ return null;
+}
diff --git a/src/main.tsx b/src/main.tsx
index f7fc0d6..2aa61c1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -9,6 +9,8 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import './i18n';
+import '@fontsource-variable/inter';
+import '@fontsource-variable/space-grotesk';
import './index.css';
import App from './App';
import LoginPage from './pages/LoginPage';
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx
index f914a3f..7324799 100644
--- a/src/pages/AdminPanel.tsx
+++ b/src/pages/AdminPanel.tsx
@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
@@ -81,6 +82,7 @@ export default function AdminPanel() {
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
+ const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const { canViewObject } = usePermissions();
const [initError, setInitError] = useState(null);
@@ -270,7 +272,7 @@ export default function AdminPanel() {
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts
index f6344f9..1e4fd1f 100644
--- a/src/stores/uiStore.ts
+++ b/src/stores/uiStore.ts
@@ -13,12 +13,15 @@ type Theme = 'light' | 'dark';
interface UIState {
theme: Theme;
sidebarOpen: boolean;
+ /** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
+ sidebarCollapsed: boolean;
activeSection: string;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
+ toggleSidebarCollapsed: () => void;
setActiveSection: (section: string) => void;
}
@@ -36,6 +39,7 @@ export const useUIStore = create()(
theme:
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
+ sidebarCollapsed: false,
activeSection: '',
toggleTheme: () => {
@@ -57,6 +61,10 @@ export const useUIStore = create()(
set({ sidebarOpen: open });
},
+ toggleSidebarCollapsed: () => {
+ set({ sidebarCollapsed: !get().sidebarCollapsed });
+ },
+
setActiveSection: (section) => {
set({ activeSection: section });
},
@@ -65,6 +73,7 @@ export const useUIStore = create()(
name: 'inbuxa-ui',
partialize: (state) => ({
theme: state.theme,
+ sidebarCollapsed: state.sidebarCollapsed,
}),
onRehydrateStorage: () => {
return (state) => {