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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user