feat(properties): harden standalone properties lifecycle

- synchronize child appearance and hidden-window readiness
- serialize native Torrent mutations transactionally without double-encoded rows
- preserve native lifecycle markers and fence stale or duplicate actions
- remove the obsolete modal surface and ignore implementation_plan.md
This commit is contained in:
NimBold
2026-08-04 12:02:30 +03:30
parent c342bcd347
commit 2ab292dd5d
20 changed files with 1230 additions and 2765 deletions
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { applyDocumentAppearance } from './documentAppearance';
const fakeDocument = () => {
const classes = new Set<string>(['theme-light', 'dark']);
const root = {
classList: {
add: (...values: string[]) => values.forEach(value => classes.add(value)),
remove: (...values: string[]) => values.forEach(value => classes.delete(value)),
},
dataset: {} as Record<string, string>,
style: {} as Record<string, string>,
lang: '',
dir: '',
};
return {
classes,
root,
document: { documentElement: root } as unknown as Document,
};
};
describe('document appearance synchronization', () => {
it('applies a complete dark RTL projection without retaining stale theme classes', () => {
const target = fakeDocument();
applyDocumentAppearance(target.document, {
theme: 'nord',
fontFamily: 'vazirmatn',
appFontSize: 'large',
listRowDensity: 'compact',
locale: 'fa',
}, false);
expect([...target.classes].sort()).toEqual(['dark', 'theme-nord']);
expect(target.root.dataset).toEqual({
resolvedTheme: 'dark',
fontFamily: 'vazirmatn',
fontSize: 'large',
listDensity: 'compact',
});
expect(target.root.style.colorScheme).toBe('dark');
expect(target.root.lang).toBe('fa');
expect(target.root.dir).toBe('rtl');
});
it('resolves system appearance while preserving an LTR locale', () => {
const target = fakeDocument();
applyDocumentAppearance(target.document, {
theme: 'system',
fontFamily: 'system',
appFontSize: 'standard',
listRowDensity: 'standard',
locale: 'en',
}, false);
expect([...target.classes]).toEqual(['theme-light']);
expect(target.root.dataset.resolvedTheme).toBe('light');
expect(target.root.style.colorScheme).toBe('light');
expect(target.root.lang).toBe('en');
expect(target.root.dir).toBe('ltr');
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { AppFontSize } from '../bindings/AppFontSize';
import type { FontFamily } from '../bindings/FontFamily';
import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { Theme } from '../bindings/Theme';
import { localeDirection, resolveAppLocale, type AppLocale } from '../i18n/locales';
export type DocumentAppearance = {
theme: Theme;
fontFamily: FontFamily;
appFontSize: AppFontSize;
listRowDensity: ListRowDensity;
locale: AppLocale;
};
const DARK_THEMES: ReadonlySet<Theme> = new Set(['dark', 'dracula', 'nord']);
const THEME_CLASSES = ['theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'] as const;
export const applyDocumentAppearance = (
document: Document,
appearance: DocumentAppearance,
systemDark: boolean,
): void => {
const root = document.documentElement;
const resolvedTheme = appearance.theme === 'system'
? (systemDark ? 'dark' : 'light')
: (DARK_THEMES.has(appearance.theme) ? 'dark' : 'light');
const themeClass = appearance.theme === 'system'
? `theme-${resolvedTheme}`
: `theme-${appearance.theme}`;
root.classList.remove(...THEME_CLASSES);
root.classList.add(themeClass);
if (resolvedTheme === 'dark') root.classList.add('dark');
root.dataset.resolvedTheme = resolvedTheme;
root.style.colorScheme = resolvedTheme;
root.dataset.fontFamily = appearance.fontFamily;
root.dataset.fontSize = appearance.appFontSize;
root.dataset.listDensity = appearance.listRowDensity;
const locale = resolveAppLocale(appearance.locale);
root.lang = locale;
root.dir = localeDirection(locale);
};
export const synchronizeDocumentAppearance = (
window: Window,
appearance: DocumentAppearance,
): (() => void) => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => applyDocumentAppearance(window.document, appearance, media.matches);
apply();
if (appearance.theme !== 'system') return () => undefined;
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
};