diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx
index ce62d3f..89bbb64 100644
--- a/src/components/forms/FieldWidget.tsx
+++ b/src/components/forms/FieldWidget.tsx
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
import { HelpTip } from '@/help/HelpTip';
import { fieldHelp } from '@/help/texts';
+import { describeDefault, differsFromDefault } from '@/help/defaults';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -88,6 +89,15 @@ export function FieldWidget(props: FieldWidgetProps) {
const { t } = useTranslation();
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props;
const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined;
+ // INBUXA: the option's default, for its tooltip, and whether it has been changed.
+ const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined;
+ const defaultWords = describeDefault(field, defaultValue, schema, {
+ on: t('field.on', 'On'),
+ off: t('field.off', 'Off'),
+ none: t('field.none', 'None'),
+ });
+ const defaultNote = defaultWords ? t('field.default', 'Default: {{value}}', { value: defaultWords }) : null;
+ const changed = defaultWords !== null && differsFromDefault(value, defaultValue);
const ft = field.type;
const edition = useEffectiveEdition();
@@ -237,7 +247,15 @@ export function FieldWidget(props: FieldWidgetProps) {
)}
-
+
+ {changed && (
+
+ {t('field.changed', 'changed')}
+
+ )}
{widget}
{sieveScriptName !== undefined && ft.type === 'string' && (
diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx
index 2d04564..80840a7 100644
--- a/src/components/lists/DynamicList.tsx
+++ b/src/components/lists/DynamicList.tsx
@@ -8,6 +8,7 @@
import { EmptyState } from '@/components/common/EmptyState';
import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel';
+import { ObjectHoverCard } from '@/features/hovercards/ObjectHoverCard';
import { iconForView } from '@/lib/viewIcon';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -270,7 +271,11 @@ function renderCellValue(
case 'objectId': {
const id = String(value);
const display = getDisplayName(ft.objectName, id);
- return display ?? id;
+ return (
+
+ {display ?? id}
+
+ );
}
case 'set': {
@@ -1305,18 +1310,27 @@ export function DynamicList({ viewName }: DynamicListProps) {
/>
)}
- {list.columns.map((col) => (
-
- {renderCellValue(
- item[col.name],
- fields[col.name],
- col.name,
- schema!,
- resolved.obj.objectName,
- getDisplayName,
- )}
- |
- ))}
+ {list.columns.map((col, colIndex) => {
+ const cell = renderCellValue(
+ item[col.name],
+ fields[col.name],
+ col.name,
+ schema!,
+ resolved.obj.objectName,
+ getDisplayName,
+ );
+ return (
+
+ {colIndex === 0 && item[col.name] != null ? (
+
+ {cell}
+
+ ) : (
+ cell
+ )}
+ |
+ );
+ })}
{hasItemActions && {renderItemActions(item)} | }
);
diff --git a/src/features/hovercards/ObjectHoverCard.tsx b/src/features/hovercards/ObjectHoverCard.tsx
new file mode 100644
index 0000000..bddebe3
--- /dev/null
+++ b/src/features/hovercards/ObjectHoverCard.tsx
@@ -0,0 +1,182 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { useEffect, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { CheckCircle2, CircleDashed, Globe, Loader2, UserRound } from 'lucide-react';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { formatSize } from '@/lib/durationFormat';
+import { cn } from '@/lib/utils';
+import { CARD_OBJECTS, loadCardFacts, type CardFacts, type DomainFacts, type PersonFacts } from './facts';
+
+const KIND_LABEL: Record = { mx: 'Mail routing', spf: 'SPF', dkim: 'DKIM', dmarc: 'DMARC' };
+
+function Row({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function DomainCard({ f }: { f: DomainFacts }) {
+ const { t } = useTranslation();
+ const auto = (m: string) => (m === 'Automatic' ? t('hover.automatic', 'Automatic') : t('hover.manual', 'Manual'));
+ const live = f.checks.filter((c) => c.live).length;
+ return (
+
+
+
+
+
+
+
{f.name}
+
+ {f.enabled ? t('hover.enabled', 'Receiving mail') : t('hover.disabled', 'Turned off')}
+ {f.people !== undefined &&
+ ` · ${t('hover.people', { count: f.people, defaultValue_one: '{{count}} person', defaultValue_other: '{{count}} people' })}`}
+
+
+
+ {f.checks.length > 0 && (
+
+
+ {live === f.checks.length
+ ? t('hover.dnsAllLive', 'DNS is in place')
+ : t('hover.dnsSome', '{{live}} of {{total}} key records live', { live, total: f.checks.length })}
+
+
+ {f.checks.map((c) => (
+
+ {c.live ? : }
+ {KIND_LABEL[c.kind] ?? c.kind}
+
+ ))}
+
+
+ )}
+
+ {auto(f.dns)}
+ {auto(f.dkim)}
+ {auto(f.certs)}
+
+
+ );
+}
+
+function PersonCard({ f }: { f: PersonFacts }) {
+ const { t, i18n } = useTranslation();
+ const pct = f.quota ? Math.min(100, Math.round((f.used / f.quota) * 100)) : null;
+ const roleLabel =
+ f.role === 'Admin'
+ ? t('hover.roleAdmin', 'Administrator')
+ : f.role === 'Custom'
+ ? t('hover.roleCustom', 'Custom role')
+ : t('hover.roleUser', 'User');
+ return (
+
+
+
+
+
+
+
{f.name ?? f.address}
+ {f.name &&
{f.address}
}
+
+
+
+
+ {t('hover.storage', 'Storage')}
+
+ {formatSize(f.used)}
+ {f.quota ? ` / ${formatSize(f.quota)}` : ''}
+
+
+ {pct !== null && (
+
+
= 90 ? 'bg-rose-500' : pct >= 75 ? 'bg-amber-500' : 'bg-primary',
+ )}
+ style={{ width: `${pct}%` }}
+ />
+
+ )}
+
+
+ {roleLabel}
+ {f.groups > 0 && {f.groups}
}
+ {f.createdAt && (
+
+ {new Date(f.createdAt).toLocaleDateString(i18n.language, { dateStyle: 'medium' })}
+
+ )}
+
+
+ );
+}
+
+function CardBody({ objectName, id }: { objectName: string; id: string }) {
+ const { t } = useTranslation();
+ const [facts, setFacts] = useState
(undefined);
+ useEffect(() => {
+ let live = true;
+ loadCardFacts(objectName, id).then((f) => {
+ if (live) setFacts(f);
+ });
+ return () => {
+ live = false;
+ };
+ }, [objectName, id]);
+ if (facts === undefined)
+ return (
+
+
+ {t('hover.loading', 'Looking…')}
+
+ );
+ if (!facts) return {t('hover.unavailable', 'No details available.')}
;
+ return facts.kind === 'domain' ? : ;
+}
+
+/**
+ * A card with the essentials of a domain or person, on hover or focus of its
+ * name in a list, so a glance answers "is this one healthy?" without
+ * opening it. Other objects render their name unchanged.
+ */
+export function ObjectHoverCard({ objectName, id, children }: { objectName: string; id: string; children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ if (!CARD_OBJECTS.has(objectName) || !id) return <>{children}>;
+ return (
+
+
+
+
+ {children}
+
+
+ e.stopPropagation()}
+ >
+ {open && }
+
+
+
+ );
+}
diff --git a/src/features/hovercards/facts.ts b/src/features/hovercards/facts.ts
new file mode 100644
index 0000000..9f3aa9b
--- /dev/null
+++ b/src/features/hovercards/facts.ts
@@ -0,0 +1,147 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * What a hover card shows, fetched when the card opens and kept for a
+ * minute, so moving across a list doesn't ask the server twice.
+ */
+import { getAccountId, jmapRequest } from '@/services/jmap/client';
+import { parseZone, type RecordKind, type ZoneRecord } from '@/features/dns/zone';
+import { checkRecords } from '@/features/dns/liveCheck';
+
+export interface DomainFacts {
+ kind: 'domain';
+ name: string;
+ enabled: boolean;
+ people?: number;
+ dns: string;
+ dkim: string;
+ certs: string;
+ /** The core records, and which are live in public DNS. */
+ checks: { kind: RecordKind; live: boolean }[];
+}
+
+export interface PersonFacts {
+ kind: 'person';
+ address: string;
+ name?: string;
+ used: number;
+ quota: number | null;
+ role?: string;
+ groups: number;
+ createdAt?: string;
+}
+
+export type CardFacts = DomainFacts | PersonFacts;
+
+/** The records a domain can't work without, in the order the card lists them. */
+export const CORE_KINDS: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc'];
+
+const TTL_MS = 60_000;
+const cache = new Map }>();
+
+function mode(value: unknown): string {
+ return ((value as { '@type'?: string } | undefined)?.['@type'] ?? 'Manual').toString();
+}
+
+/** Of the domain's core records, which are live. Kinds with no records (DKIM before keys exist) are left out. */
+export async function coreChecks(zone: ZoneRecord[], domain: string): Promise<{ kind: RecordKind; live: boolean }[]> {
+ const apex = domain.toLowerCase();
+ const core = zone.filter((r) => CORE_KINDS.includes(r.kind) && (r.kind !== 'spf' || r.name.toLowerCase() === apex));
+ const states = await checkRecords(core);
+ return CORE_KINDS.filter((k) => core.some((r) => r.kind === k)).map((kind) => ({
+ kind,
+ live: core.filter((r) => r.kind === kind).every((r) => states.get(r) === 'live'),
+ }));
+}
+
+async function domainFacts(id: string): Promise {
+ const accountId = getAccountId('x:Domain');
+ const responses = await jmapRequest([
+ [
+ 'x:Domain/get',
+ {
+ accountId,
+ ids: [id],
+ properties: ['name', 'isEnabled', 'dnsManagement', 'dkimManagement', 'certificateManagement', 'dnsZoneFile'],
+ },
+ 'd',
+ ],
+ [
+ 'x:Account/query',
+ { accountId: getAccountId('x:Account'), filter: { domainId: id }, limit: 1, calculateTotal: true },
+ 'n',
+ ],
+ ]);
+ const d = (responses.find((r) => r[2] === 'd')?.[1] as { list?: Record[] })?.list?.[0];
+ if (!d) return null;
+ const count = responses.find((r) => r[2] === 'n' && r[0] !== 'error')?.[1] as { total?: number } | undefined;
+ const name = String(d.name);
+ const checks = await coreChecks(parseZone(d.dnsZoneFile as string | undefined), name).catch(
+ (): DomainFacts['checks'] => [],
+ );
+ return {
+ kind: 'domain',
+ name,
+ enabled: d.isEnabled !== false,
+ people: count?.total,
+ dns: mode(d.dnsManagement),
+ dkim: mode(d.dkimManagement),
+ certs: mode(d.certificateManagement),
+ checks,
+ };
+}
+
+async function personFacts(id: string): Promise {
+ const responses = await jmapRequest([
+ [
+ 'x:Account/get',
+ {
+ accountId: getAccountId('x:Account'),
+ ids: [id],
+ properties: [
+ '@type',
+ 'emailAddress',
+ 'name',
+ 'description',
+ 'usedDiskQuota',
+ 'quotas',
+ 'roles',
+ 'memberGroupIds',
+ 'createdAt',
+ ],
+ },
+ 'a',
+ ],
+ ]);
+ const a = (responses[0]?.[1] as { list?: Record[] })?.list?.[0];
+ if (!a) return null;
+ const quotas = (a.quotas ?? {}) as Record;
+ const quota = typeof quotas.maxDiskQuota === 'number' && quotas.maxDiskQuota > 0 ? quotas.maxDiskQuota : null;
+ const groups = a.memberGroupIds && typeof a.memberGroupIds === 'object' ? Object.keys(a.memberGroupIds).length : 0;
+ return {
+ kind: 'person',
+ address: String(a.emailAddress ?? a.name ?? id),
+ name: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
+ used: typeof a.usedDiskQuota === 'number' ? a.usedDiskQuota : 0,
+ quota,
+ role: (a.roles as { '@type'?: string } | undefined)?.['@type'],
+ groups,
+ createdAt: typeof a.createdAt === 'string' ? a.createdAt : undefined,
+ };
+}
+
+/** Object types that have a hover card. */
+export const CARD_OBJECTS = new Set(['x:Domain', 'x:Account']);
+
+export function loadCardFacts(objectName: string, id: string): Promise {
+ const key = `${objectName}|${id}`;
+ const hit = cache.get(key);
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.facts;
+ const facts = (objectName === 'x:Domain' ? domainFacts(id) : personFacts(id)).catch(() => null);
+ cache.set(key, { at: Date.now(), facts });
+ return facts;
+}
diff --git a/src/help/HelpTip.tsx b/src/help/HelpTip.tsx
index 9485a97..4162174 100644
--- a/src/help/HelpTip.tsx
+++ b/src/help/HelpTip.tsx
@@ -16,9 +16,20 @@ import { manualUrl } from './manual';
* focus, and a way into the manual once there is one. `id` is the option's
* stable help id, the key the manual links hang on.
*/
-export function HelpTip({ id, text, className }: { id?: string; text?: string | null; className?: string }) {
+export function HelpTip({
+ id,
+ text,
+ footnote,
+ className,
+}: {
+ id?: string;
+ text?: string | null;
+ /** A short line under the text, like the option's default. */
+ footnote?: string | null;
+ className?: string;
+}) {
const { t } = useTranslation();
- if (!text) return null;
+ if (!text && !footnote) return null;
const more = id ? manualUrl(id) : null;
return (
@@ -42,8 +53,9 @@ export function HelpTip({ id, text, className }: { id?: string; text?: string |
className="max-w-xs space-y-1.5 border bg-popover px-3 py-2 text-xs leading-relaxed text-popover-foreground shadow-soft"
>
- {text.replace(/\\n/g, '\n')}
+ {text && {text.replace(/\\n/g, '\n')}}
+ {footnote && {footnote}
}
{more && (
) => ({ description: '', update: 'mutable', type }) as unknown as Field;
+
+describe('describeDefault', () => {
+ it('says defaults the way people would', () => {
+ expect(describeDefault(f({ type: 'boolean' }), true, schema, words)).toBe('On');
+ expect(describeDefault(f({ type: 'number', format: 'duration' }), 300000, schema, words)).toBe('5m');
+ expect(describeDefault(f({ type: 'enum', enumName: 'Proto' }), 'udp', schema, words)).toBe('UDP');
+ expect(describeDefault(f({ type: 'object', objectName: 'x:Mgmt' }), { '@type': 'Manual' }, schema, words)).toBe(
+ 'Manual DNS management',
+ );
+ expect(
+ describeDefault(
+ f({ type: 'set', class: { type: 'enum', enumName: 'RecType' } }),
+ { mx: true, spf: true },
+ schema,
+ words,
+ ),
+ ).toBe('MX records, SPF records');
+ expect(describeDefault(f({ type: 'string' }), 'mailto:postmaster', schema, words)).toBe('mailto:postmaster');
+ });
+
+ it('stays quiet without a default', () => {
+ expect(describeDefault(f({ type: 'string' }), undefined, schema, words)).toBeNull();
+ });
+});
+
+describe('differsFromDefault', () => {
+ it('compares by value, ignoring key order', () => {
+ expect(differsFromDefault({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(false);
+ expect(differsFromDefault(600000, 300000)).toBe(true);
+ expect(differsFromDefault(undefined, 300000)).toBe(false);
+ expect(differsFromDefault(true, undefined)).toBe(false);
+ });
+});
diff --git a/src/help/defaults.ts b/src/help/defaults.ts
new file mode 100644
index 0000000..a1628e2
--- /dev/null
+++ b/src/help/defaults.ts
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * An option's default, as words, and whether the current value differs
+ * from it, so tooltips can say "Default: 5 min" and a form can mark what
+ * someone has changed.
+ */
+import type { Field, Schema } from '@/types/schema';
+import { formatDuration, formatSize } from '@/lib/durationFormat';
+
+function stable(v: unknown): string {
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
+ const o = v as Record;
+ return `{${Object.keys(o)
+ .sort()
+ .map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
+ .join(',')}}`;
+ }
+ return JSON.stringify(v ?? null);
+}
+
+/** Does the value differ from the default? An unset value counts as the default. */
+export function differsFromDefault(value: unknown, def: unknown): boolean {
+ if (def === undefined) return false;
+ if (value === undefined || value === null) return false;
+ return stable(value) !== stable(def);
+}
+
+/** The default as someone would say it, or null when there's nothing short to say. */
+export function describeDefault(
+ field: Field,
+ def: unknown,
+ schema: Schema,
+ words: { on: string; off: string; none: string },
+): string | null {
+ if (def === undefined) return null;
+ if (def === null) return words.none;
+ const ft = field.type as { type: string; format?: string; enumName?: string; objectName?: string };
+ if (typeof def === 'boolean') return def ? words.on : words.off;
+ if (typeof def === 'number') {
+ if (ft.format === 'duration') return formatDuration(def);
+ if (ft.format === 'size') return formatSize(def);
+ return String(def);
+ }
+ if (typeof def === 'string') {
+ if (ft.type === 'enum' && ft.enumName) {
+ return schema.enums[ft.enumName]?.find((e) => e.name === def)?.label ?? def;
+ }
+ return def.length > 60 ? null : def;
+ }
+ if (typeof def === 'object' && !Array.isArray(def)) {
+ const variant = (def as Record)['@type'];
+ if (typeof variant === 'string' && ft.objectName) {
+ const sch = schema.schemas[ft.objectName];
+ const label = sch?.type === 'multiple' ? sch.variants.find((v) => v.name === variant)?.label : undefined;
+ return label ?? variant;
+ }
+ const keys = Object.keys(def as Record);
+ if (ft.type === 'set') {
+ if (keys.length === 0) return words.none;
+ if (ft.enumName || (field.type as { class?: { enumName?: string } }).class?.enumName) {
+ const en = (field.type as { class?: { enumName?: string } }).class?.enumName ?? ft.enumName!;
+ const labels = keys.map((k) => schema.enums[en]?.find((e) => e.name === k)?.label ?? k);
+ return labels.length > 4 ? `${labels.slice(0, 4).join(', ')}…` : labels.join(', ');
+ }
+ return keys.length > 4 ? `${keys.slice(0, 4).join(', ')}…` : keys.join(', ');
+ }
+ }
+ return null;
+}