feat(notifications): add overlay notifications with position config

- Add Catppuccin Macchiato overlay notification stack with 3s transient timeout
- Add `notifications.overlayPosition` config (top-left | top | top-right)
- Route startup tokenization and subtitle annotation status through configured surfaces
- Deduplicate rapid subtitle mode toggle notifications
- Change `both` to mean overlay + system; add `osd-system` as legacy alias for old behavior
- Keep `osd`/`osd-system` as config-file-only legacy values; Settings UI offers overlay/system/both/none
This commit is contained in:
2026-06-04 21:56:51 -07:00
parent 311f1e8ee5
commit 9247248d48
83 changed files with 2296 additions and 240 deletions
@@ -1,11 +1,13 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
export interface CharacterDictionaryAutoSyncNotificationDeps {
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
getNotificationType: () => NotificationType | undefined;
showOsd: (message: string) => boolean | void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
startupOsdSequencer?: {
notifyCharacterDictionaryStatus: (
@@ -14,39 +16,63 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
};
}
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return type !== 'none';
function shouldShowOsd(type: NotificationType): boolean {
return type === 'osd' || type === 'osd-system';
}
function shouldFallbackToDesktop(
type: 'osd' | 'system' | 'both' | 'none' | undefined,
function shouldShowOverlay(type: NotificationType): boolean {
return type === 'overlay' || type === 'both';
}
function shouldShowDesktop(type: NotificationType): boolean {
return type === 'system' || type === 'both' || type === 'osd-system';
}
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
return phase === 'ready' || phase === 'failed';
}
function overlayVariantForPhase(
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
): boolean {
return (
(type === 'system' || type === 'both') &&
(phase === 'generating' || phase === 'building' || phase === 'importing')
);
): OverlayNotificationPayload['variant'] {
if (phase === 'ready') return 'success';
if (phase === 'failed') return 'error';
return 'progress';
}
export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps,
): void {
const type = deps.getNotificationType();
if (shouldShowOsd(type)) {
if (deps.startupOsdSequencer) {
const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
const type = deps.getNotificationType() ?? 'overlay';
if (type === 'none') return;
if (shouldShowOverlay(type)) {
if (deps.showOverlayNotification) {
deps.showOverlayNotification({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: event.message,
variant: overlayVariantForPhase(event.phase),
persistent: !isTerminalPhase(event.phase),
});
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
return;
}
const shown = deps.showOsd(event.message) !== false;
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
} else if (!shouldShowDesktop(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}
if (shouldShowOsd(type)) {
if (deps.startupOsdSequencer) {
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
});
} else {
deps.showOsd(event.message);
}
}
if (shouldShowDesktop(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}