A warmer, friendlier admin: first pass

- The INBUXA palette on warm surfaces, softer cards, buttons and inputs, and
  self-hosted Inter and Space Grotesk.
- Each section's icon sits on a colored tile, colored by what the section is
  about.
- The sidebar gets a guide line for sub-pages and a labeled Management /
  Settings / Account switch, and folds to a rail of tiles (remembered).
- Every page has a header with its section's tile.
- Fields and pages with no label of their own are spelled out in words
  (defaultCertificateId becomes Default certificate ID).
- The dashboard greets you, and its stat cards wear colored tiles.
- Unavailable live numbers are a calm note, not an error.
- The cat appears in empty lists and while loading.
- Chart colors work again: they were hex values wrapped in hsl().
This commit is contained in:
2026-09-19 00:38:53 -07:00
parent 92bcb58f76
commit e4ad5e2c1e
26 changed files with 789 additions and 161 deletions
+26
View File
@@ -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');
});
});
+70
View File
@@ -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<string, string> = {
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(' ');
}
+72
View File
@@ -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<string, Tone> = {
'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];
}
+23
View File
@@ -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;
}