feat(overlay): add loading OSD spinner and queue notifications until ren

- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting
- Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable
- Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods
- Defer background warmups until after overlay runtime init so queued notifications can deliver promptly
- Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
This commit is contained in:
2026-06-07 23:13:51 -07:00
parent d033884b09
commit 9d77907877
49 changed files with 1613 additions and 132 deletions
@@ -3,6 +3,7 @@ import test from 'node:test';
import type { OverlayNotificationEntry } from './overlay-notifications';
import {
createOverlayNotificationHistoryPanel,
createOverlayNotificationHistoryStore,
resolveHistorySideFromStack,
} from './overlay-notification-history';
@@ -54,6 +55,46 @@ test('history store updates an entry in place without reordering or duplicating'
assert.equal(job?.updatedAt, 200);
});
test('history store keeps same live notification id when history ids differ', () => {
const store = createOverlayNotificationHistoryStore();
store.record(
entry({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Checking character dictionary...',
variant: 'progress',
historyId: 'character-dictionary-auto-sync-checking',
}),
);
store.record(
entry({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Building character dictionary...',
variant: 'progress',
historyId: 'character-dictionary-auto-sync-building',
}),
);
store.record(
entry({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Character dictionary ready',
variant: 'success',
historyId: 'character-dictionary-auto-sync-ready',
}),
);
assert.deepEqual(
store.list().map((item) => `${item.id}:${item.body}`),
[
'character-dictionary-auto-sync-ready:Character dictionary ready',
'character-dictionary-auto-sync-building:Building character dictionary...',
'character-dictionary-auto-sync-checking:Checking character dictionary...',
],
);
});
test('history store removes and clears entries', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a' }));
@@ -98,3 +139,99 @@ test('panel side mirrors the notification stack position', () => {
// Center notifications open the panel from the right.
assert.equal(resolveHistorySideFromStack(stackWith('position-top')), 'right');
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
function createPanelHarness(stackPositionClass: string) {
const stack = {
classList: createClassList([stackPositionClass]),
};
const clearButton = {
disabled: false,
addEventListener: () => undefined,
};
const closeButton = {
addEventListener: () => undefined,
};
const list = {
replaceChildren: () => undefined,
};
const empty = {
classList: createClassList(),
};
const panel = {
classList: createClassList(['notification-history', 'side-right']),
setAttribute: () => undefined,
addEventListener: () => undefined,
querySelector: (selector: string) => {
switch (selector) {
case '.notification-history-list':
return list;
case '.notification-history-empty':
return empty;
case '.notification-history-clear':
return clearButton;
case '.notification-history-close':
return closeButton;
default:
return null;
}
},
};
const controller = createOverlayNotificationHistoryPanel({
dom: {
overlayNotificationHistory: panel,
overlayNotificationStack: stack,
},
state: {
isOverNotificationHistory: false,
notificationHistoryOpen: false,
},
platform: {
shouldToggleMouseIgnore: false,
},
} as never);
return { controller, panel, stack };
}
test('history panel applies the initial stack side while still closed', () => {
const { panel } = createPanelHarness('position-top-left');
assert.equal(panel.classList.contains('side-left'), true);
assert.equal(panel.classList.contains('side-right'), false);
assert.equal(panel.classList.contains('open'), false);
});
test('history panel resyncs the closed side before first open', () => {
const { controller, panel, stack } = createPanelHarness('position-top-right');
stack.classList.remove('position-top-right');
stack.classList.add('position-top-left');
const syncable = controller as unknown as { syncSide?: () => void };
assert.equal(typeof syncable.syncSide, 'function');
syncable.syncSide?.();
assert.equal(panel.classList.contains('side-left'), true);
assert.equal(panel.classList.contains('side-right'), false);
assert.equal(panel.classList.contains('open'), false);
});