From f5ed12f5cb61200fc98826d99c4126622c4cfa14 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:41:36 +0100 Subject: [PATCH 01/65] refactor: keep the density tables and legacy settings tabs in plain modules --- components/settings/settings-app.tsx | 25 ++-------- lib/density.ts | 68 ++++++++++++++++++++++++++++ lib/settings-tabs.ts | 35 ++++++++++++++ stores/settings-store.ts | 61 ++----------------------- 4 files changed, 111 insertions(+), 78 deletions(-) create mode 100644 lib/density.ts create mode 100644 lib/settings-tabs.ts diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 91fa6f634..dbe7ab821 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -1,6 +1,7 @@ "use client"; import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; +import { LEGACY_TAB_MAP } from "@/lib/settings-tabs"; import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations, useMessages } from 'next-intl'; @@ -154,27 +155,9 @@ const tabIcons: Record = { const tabGroupOrder: TabGroup[] = ['top', 'mail', 'apps', 'bottom']; -// Map legacy tab IDs to current ones; runs once on read of localStorage and -// for every deep link, so old bookmarks, connector links and sidebar gears -// land on the page that now holds the setting. -export const LEGACY_TAB_MAP: Record = { - email: 'reading', - advanced: 'backup', - layout: 'appearance', - themes: 'appearance', - content_senders: 'reading', - identities: 'account', - language: 'general', - protocol_handlers: 'general', - templates: 'composing', - folders: 'organize', - keywords: 'organize', - downloads: 'reading', - sidebar_apps: 'addons', - plugins: 'addons', - about_data: 'backup', - debug: 'backup', -}; +// Legacy tab ids and their current pages live in lib/settings-tabs.ts, which +// the pre-paint boot script reads too. +export { LEGACY_TAB_MAP }; // Where on the new page a legacy tab's settings now sit, so an old link // lands on that section rather than at the top of the page. diff --git a/lib/density.ts b/lib/density.ts new file mode 100644 index 000000000..4e1a4beb1 --- /dev/null +++ b/lib/density.ts @@ -0,0 +1,68 @@ +/** + * The CSS custom properties each list density writes on . + * + * Shared by the settings store, which applies them when JS runs, and by the + * boot script in app/(main)/layout.tsx, which applies the same values before + * the first paint so the skeleton and the loaded page share one row rhythm. + */ + +export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; +export type InterfaceLayout = 'default' | 'mountain-view'; + +export type DensityVars = Record; + +export const DEFAULT_DENSITY: Record = { + 'extra-compact': { + '--list-item-height': 'auto', + '--density-item-py': '2px', + '--density-item-gap': '6px', + '--density-header-py': '4px', + '--density-card-p': '8px', + '--density-sidebar-py': '0px', + }, + compact: { + '--list-item-height': 'auto', + '--density-item-py': '4px', + '--density-item-gap': '8px', + '--density-header-py': '6px', + '--density-card-p': '10px', + '--density-sidebar-py': '1px', + }, + regular: { + '--list-item-height': '48px', + '--density-item-py': '12px', + '--density-item-gap': '12px', + '--density-header-py': '12px', + '--density-card-p': '16px', + '--density-sidebar-py': '4px', + }, + comfortable: { + '--list-item-height': '64px', + '--density-item-py': '16px', + '--density-item-gap': '16px', + '--density-header-py': '16px', + '--density-card-p': '20px', + '--density-sidebar-py': '6px', + }, +}; + +/** + * Mountain View's rows come in three rhythms - 32, 40 and 48 pixels - and the + * app offers four densities. The four are laid over that scale: `compact`, + * `regular` and `comfortable` land on the three exactly, and `extra-compact` + * continues the series a step below, so every density still does something. + * + * Only the vertical rhythm is restated. Everything else a density touches - + * gaps, card padding, the sidebar's own row padding - keeps its default value; + * a navigation row is already pinned to 32px by the layout's own token. + */ +export const MOUNTAIN_VIEW_DENSITY: Record = { + 'extra-compact': { ...DEFAULT_DENSITY['extra-compact'], '--list-item-height': '28px', '--density-item-py': '4px' }, + compact: { ...DEFAULT_DENSITY.compact, '--list-item-height': '32px', '--density-item-py': '6px' }, + regular: { ...DEFAULT_DENSITY.regular, '--list-item-height': '40px', '--density-item-py': '10px' }, + comfortable: { ...DEFAULT_DENSITY.comfortable, '--list-item-height': '48px', '--density-item-py': '14px' }, +}; + +export function densityVarsFor(density: Density, layout: InterfaceLayout): DensityVars { + return (layout === 'mountain-view' ? MOUNTAIN_VIEW_DENSITY : DEFAULT_DENSITY)[density]; +} diff --git a/lib/settings-tabs.ts b/lib/settings-tabs.ts new file mode 100644 index 000000000..173203596 --- /dev/null +++ b/lib/settings-tabs.ts @@ -0,0 +1,35 @@ +import type { SettingsSearchTab } from '@/lib/settings-search'; + +/** + * Every settings page id, in no particular order. A `/settings/` link + * that names none of these (and no plugin page) falls back to the stored tab. + */ +export const SETTINGS_TAB_IDS: readonly SettingsSearchTab[] = [ + 'account', 'general', 'notifications', 'appearance', 'accessibility', + 'message_list', 'reading', 'composing', 'vacation', 'filters', 'organize', + 'security', 'sharing', 'recovery', 'calendar', 'contacts', 'files', + 'addons', 'backup', +]; + +// Map legacy tab IDs to current ones; runs once on read of localStorage and +// for every deep link, so old bookmarks, connector links and sidebar gears +// land on the page that now holds the setting. The pre-paint boot script +// uses it too, to pick the settings skeleton's active row. +export const LEGACY_TAB_MAP: Record = { + email: 'reading', + advanced: 'backup', + layout: 'appearance', + themes: 'appearance', + content_senders: 'reading', + identities: 'account', + language: 'general', + protocol_handlers: 'general', + templates: 'composing', + folders: 'organize', + keywords: 'organize', + downloads: 'reading', + sidebar_apps: 'addons', + plugins: 'addons', + about_data: 'backup', + debug: 'backup', +}; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2427a5965..6d79f88dd 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { densityVarsFor } from '@/lib/density'; import { persist } from 'zustand/middleware'; import { useThemeStore } from './theme-store'; import { useLocaleStore } from './locale-store'; @@ -2155,63 +2156,9 @@ function applyAccessibility(state: { fontSize: FontSize } & AccessibilitySetting applyAccessibilityTo(document.documentElement, state); } -type DensityVars = Record; - -const DEFAULT_DENSITY: Record = { - 'extra-compact': { - '--list-item-height': 'auto', - '--density-item-py': '2px', - '--density-item-gap': '6px', - '--density-header-py': '4px', - '--density-card-p': '8px', - '--density-sidebar-py': '0px', - }, - compact: { - '--list-item-height': 'auto', - '--density-item-py': '4px', - '--density-item-gap': '8px', - '--density-header-py': '6px', - '--density-card-p': '10px', - '--density-sidebar-py': '1px', - }, - regular: { - '--list-item-height': '48px', - '--density-item-py': '12px', - '--density-item-gap': '12px', - '--density-header-py': '12px', - '--density-card-p': '16px', - '--density-sidebar-py': '4px', - }, - comfortable: { - '--list-item-height': '64px', - '--density-item-py': '16px', - '--density-item-gap': '16px', - '--density-header-py': '16px', - '--density-card-p': '20px', - '--density-sidebar-py': '6px', - }, -}; - -/** - * Mountain View's rows come in three rhythms - 32, 40 and 48 pixels - and the - * app offers four densities. The four are laid over that scale: `compact`, - * `regular` and `comfortable` land on the three exactly, and `extra-compact` - * continues the series a step below, so every density still does something. - * - * Only the vertical rhythm is restated. Everything else a density touches - - * gaps, card padding, the sidebar's own row padding - keeps its default value; - * a navigation row is already pinned to 32px by the layout's own token. - */ -const MOUNTAIN_VIEW_DENSITY: Record = { - 'extra-compact': { ...DEFAULT_DENSITY['extra-compact'], '--list-item-height': '28px', '--density-item-py': '4px' }, - compact: { ...DEFAULT_DENSITY.compact, '--list-item-height': '32px', '--density-item-py': '6px' }, - regular: { ...DEFAULT_DENSITY.regular, '--list-item-height': '40px', '--density-item-py': '10px' }, - comfortable: { ...DEFAULT_DENSITY.comfortable, '--list-item-height': '48px', '--density-item-py': '14px' }, -}; - -export function densityVarsFor(density: Density, layout: InterfaceLayout): DensityVars { - return (layout === 'mountain-view' ? MOUNTAIN_VIEW_DENSITY : DEFAULT_DENSITY)[density]; -} +// The tables live in lib/density.ts so the pre-paint boot script can use +// the same values (app/(main)/layout.tsx). +export { densityVarsFor }; function applyDensity(density: Density, layout: InterfaceLayout) { if (typeof document === 'undefined') return; From 943276696bc1a9cc6f4fd0457a04ef4743a7d33a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:41:58 +0100 Subject: [PATCH 02/65] feat: paint the saved theme, density and layout before the app loads --- app/(main)/layout.tsx | 3 + .../__tests__/boot-script.test.ts | 464 ++++++++++++++++++ .../__tests__/theme-cache.test.ts | 75 +++ lib/boot-skeleton/boot-script.ts | 309 ++++++++++++ lib/boot-skeleton/keys.ts | 6 + lib/boot-skeleton/surfaces.ts | 79 +++ lib/theme-loader.ts | 55 +++ stores/theme-store.ts | 7 + 8 files changed, 998 insertions(+) create mode 100644 lib/boot-skeleton/__tests__/boot-script.test.ts create mode 100644 lib/boot-skeleton/__tests__/theme-cache.test.ts create mode 100644 lib/boot-skeleton/boot-script.ts create mode 100644 lib/boot-skeleton/keys.ts create mode 100644 lib/boot-skeleton/surfaces.ts diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 208656678..d186b72da 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -19,6 +19,7 @@ import { withBasePath } from "@/lib/browser-navigation"; import { locales, defaultLocale } from "@/i18n/routing"; import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite"; import { accessibilityBootScript } from "@/lib/accessibility-settings"; +import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script"; import "../globals.css"; import "../print.css"; @@ -314,10 +315,12 @@ export default async function RootLayout({ const resolved = theme === 'system' ? systemTheme : theme; document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.add(resolved); + document.documentElement.style.colorScheme = resolved; } catch (e) { document.documentElement.classList.add('light'); } ${accessibilityBootScript()} + ${bootSkeletonScript()} })(); `, }} diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts new file mode 100644 index 000000000..ee151e6bc --- /dev/null +++ b/lib/boot-skeleton/__tests__/boot-script.test.ts @@ -0,0 +1,464 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { bootConfig, bootSkeletonScript, type BootLayoutConfig } from '../boot-script'; +import { THEME_CACHE_KEY } from '../keys'; + +/** + * Runs the exact string the root layout inlines into , in a scope with + * none of this module's imports: a function that reached for one would throw + * here (and in the browser), and the test would see default attributes. + */ +function boot(path: string, config: Partial = {}) { + window.history.replaceState(null, '', path); + new Function(bootSkeletonScript({ ...bootConfig(), ...config }))(); +} + +const html = () => document.documentElement; +const attr = (name: string) => html().getAttribute(`data-boot-${name}`); +const cssVar = (name: string) => html().style.getPropertyValue(name); +const settings = (state: Record) => + localStorage.setItem('settings-storage', JSON.stringify({ state, version: 12 })); +const signedIn = () => + localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true }, version: 0 })); + +function desktop(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: matches && query.includes('64rem'), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + for (const a of [...html().attributes]) { + if (a.name.startsWith('data-boot-') || a.name === 'data-interface-layout' || a.name === 'data-theme-skin' || a.name === 'style') { + html().removeAttribute(a.name); + } + } + document.head.querySelectorAll('#active-theme, #active-theme-skin').forEach((el) => el.remove()); + desktop(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('boot script: defaults', () => { + it('draws the split inbox with the regular density and default widths when nothing is stored', () => { + boot('/en/mail/folder/inbox'); + expect(attr('surface')).toBe('mail'); + expect(attr('mail-layout')).toBe('split'); + expect(html().getAttribute('data-interface-layout')).toBe('default'); + expect(cssVar('--list-item-height')).toBe('48px'); + expect(cssVar('--density-item-py')).toBe('12px'); + expect(cssVar('--boot-mail-sidebar-w')).toBe('256px'); + expect(cssVar('--boot-mail-list-w')).toBe('384px'); + expect(cssVar('--boot-mail-list-h')).toBe('320px'); + expect(attr('pane')).toBeNull(); + expect(attr('pro')).toBeNull(); + }); + + it('treats the bare root as mail', () => { + boot('/'); + expect(attr('surface')).toBe('mail'); + }); + + it('survives corrupt JSON in every key it reads', () => { + for (const key of ['settings-storage', 'column-widths', 'calendar-storage', 'auth-storage', 'account-registry', 'bulwark-boot-hints', THEME_CACHE_KEY]) { + localStorage.setItem(key, '{not json'); + } + boot('/en/calendar'); + expect(attr('surface')).toBe('calendar'); + expect(attr('calendar-view')).toBe('month'); + expect(cssVar('--list-item-height')).toBe('48px'); + expect(cssVar('--boot-mail-sidebar-w')).toBe('256px'); + expect(attr('rail')).toBeNull(); + expect(document.getElementById('active-theme')).toBeNull(); + }); + + it('survives storage that throws on access', () => { + const throwing = { get: () => { throw new Error('SecurityError'); } }; + const local = Object.getOwnPropertyDescriptor(window, 'localStorage'); + const session = Object.getOwnPropertyDescriptor(window, 'sessionStorage'); + Object.defineProperty(window, 'localStorage', { configurable: true, ...throwing }); + Object.defineProperty(window, 'sessionStorage', { configurable: true, ...throwing }); + try { + boot('/en/settings/filters'); + expect(attr('surface')).toBe('settings'); + expect(attr('settings-tab')).toBe('filters'); + expect(cssVar('--boot-settings-nav-w')).toBe('256px'); + } finally { + Object.defineProperty(window, 'localStorage', local!); + Object.defineProperty(window, 'sessionStorage', session!); + } + }); + + it('ignores state that is not an object', () => { + localStorage.setItem('settings-storage', JSON.stringify({ state: 'oops' })); + localStorage.setItem('column-widths', JSON.stringify([1, 2])); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('split'); + expect(cssVar('--boot-mail-list-w')).toBe('384px'); + }); +}); + +describe('boot script: layout settings', () => { + it('applies Mountain View, its density rhythm and forces the focus layout', () => { + settings({ interfaceLayout: 'mountain-view', density: 'compact', mailLayout: 'split' }); + boot('/en/mail'); + expect(html().getAttribute('data-interface-layout')).toBe('mountain-view'); + expect(cssVar('--list-item-height')).toBe('32px'); + expect(cssVar('--density-item-py')).toBe('6px'); + expect(cssVar('--density-item-gap')).toBe('8px'); + expect(attr('mail-layout')).toBe('focus'); + }); + + it.each([ + ['extra-compact', 'auto', '2px', '0px'], + ['compact', 'auto', '4px', '1px'], + ['regular', '48px', '12px', '4px'], + ['comfortable', '64px', '16px', '6px'], + ])('writes the %s density variables', (density, height, py, sidebar) => { + settings({ density }); + boot('/en/mail'); + expect(cssVar('--list-item-height')).toBe(height); + expect(cssVar('--density-item-py')).toBe(py); + expect(cssVar('--density-sidebar-py')).toBe(sidebar); + }); + + it('falls back to regular for an unknown density', () => { + settings({ density: 'cosy' }); + boot('/en/mail'); + expect(cssVar('--list-item-height')).toBe('48px'); + }); + + it('keeps the horizontal and focus layouts, and simple mode forces focus', () => { + settings({ mailLayout: 'horizontal' }); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('horizontal'); + settings({ mailLayout: 'focus' }); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('focus'); + settings({ mailLayout: 'horizontal', simpleMode: true }); + boot('/en/mail'); + expect(attr('mail-layout')).toBe('focus'); + }); + + it('clamps stored column widths the way the mail store does', () => { + localStorage.setItem('column-widths', JSON.stringify({ sidebarWidth: 1000, emailListWidth: 100, emailListHeight: 500 })); + boot('/en/mail'); + expect(cssVar('--boot-mail-sidebar-w')).toBe('400px'); + expect(cssVar('--boot-mail-list-w')).toBe('240px'); + expect(cssVar('--boot-mail-list-h')).toBe('500px'); + }); + + it('reads the other surfaces\' widths, defaulting bad values', () => { + localStorage.setItem('calendar-sidebar-width', '300'); + localStorage.setItem('contacts-sidebar-width', 'abc'); + localStorage.setItem('contacts-list-width', '450'); + localStorage.setItem('files-sidebar-width', '999'); + localStorage.setItem('settings-sidebar-width', '210'); + boot('/en/contacts'); + expect(cssVar('--boot-cal-sidebar-w')).toBe('300px'); + expect(cssVar('--boot-contacts-sidebar-w')).toBe('256px'); + expect(cssVar('--boot-contacts-list-w')).toBe('450px'); + expect(cssVar('--boot-files-sidebar-w')).toBe('400px'); + expect(cssVar('--boot-settings-nav-w')).toBe('210px'); + }); + + it('marks the files view and details panel', () => { + settings({ filesSettings: { defaultViewMode: 'grid' } }); + localStorage.setItem('files-show-details', '1'); + boot('/en/files/Documents'); + expect(attr('surface')).toBe('files'); + expect(attr('files-view')).toBe('grid'); + expect(attr('files-details')).toBe('on'); + }); +}); + +describe('boot script: mail and contacts panes', () => { + it.each([ + ['/en/mail/message/M123', 'message'], + ['/en/mail/thread/T9', 'thread'], + ['/en/mail?email=M1', 'message'], + ['/en/mail/folder/inbox', null], + ['/en/mail/message', null], + ])('%s opens pane %s', (path, pane) => { + boot(path); + expect(attr('pane')).toBe(pane); + }); + + it.each([ + ['/en/contacts', null], + ['/en/contacts/new', 'detail'], + ['/en/contacts/abc/edit', 'detail'], + ['/en/contacts?contactId=abc', 'detail'], + ])('%s opens pane %s', (path, pane) => { + boot(path); + expect(attr('surface')).toBe('contacts'); + expect(attr('pane')).toBe(pane); + }); +}); + +describe('boot script: calendar', () => { + it.each(['month', 'week', 'day', 'agenda', 'tasks'])('takes the %s view from the URL', (view) => { + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'agenda' } })); + boot(`/en/calendar/${view}/2026-11-04`); + expect(attr('calendar-view')).toBe(view); + }); + + it('opens a bare date in month view, like parseCalendarPath', () => { + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' } })); + boot('/en/calendar/2026-11-04'); + expect(attr('calendar-view')).toBe('month'); + }); + + it('uses the stored view for /calendar and event links, month without one', () => { + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'day' } })); + boot('/en/calendar'); + expect(attr('calendar-view')).toBe('day'); + boot('/en/calendar/event/E1'); + expect(attr('calendar-view')).toBe('day'); + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'year' } })); + boot('/en/calendar'); + expect(attr('calendar-view')).toBe('month'); + }); + + it('counts week columns and display hours from the settings', () => { + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-cols')).toBe('7'); + expect(cssVar('--boot-cal-hours')).toBe('12'); + settings({ calendarHideNonWorkingDays: true, calendarWorkingDays: [1, 2, 3, 4, 5], calendarDayStartHour: 7, calendarDayEndHour: 19 }); + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-cols')).toBe('5'); + expect(cssVar('--boot-cal-hours')).toBe('12'); + settings({ calendarLimitHours: false, calendarHideNonWorkingDays: true, calendarWorkingDays: [] }); + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-cols')).toBe('7'); + expect(cssVar('--boot-cal-hours')).toBe('24'); + settings({ calendarDayStartHour: 20, calendarDayEndHour: 8 }); + boot('/en/calendar/week'); + expect(cssVar('--boot-cal-hours')).toBe('24'); + }); + + it('marks a collapsed calendar sidebar', () => { + localStorage.setItem('calendar-sidebar-collapsed', '1'); + boot('/en/calendar'); + expect(attr('collapsed')).toBe('calendar'); + }); +}); + +describe('boot script: settings', () => { + it('opens the page a URL names, migrating legacy ids', () => { + boot('/en/settings/themes'); + expect(attr('settings-tab')).toBe('appearance'); + expect(attr('pane')).toBe('detail'); + boot('/en/settings/filters/rules'); + expect(attr('settings-tab')).toBe('filters'); + }); + + it('falls back to the stored tab for an unknown or missing URL tab', () => { + localStorage.setItem('settings-active-tab', 'keywords'); + boot('/en/settings/no-such-tab'); + expect(attr('settings-tab')).toBe('organize'); + expect(attr('pane')).toBeNull(); + boot('/en/settings'); + expect(attr('settings-tab')).toBe('organize'); + }); + + it('defaults to appearance, and honours the one-shot sidebar gear link', () => { + boot('/en/settings'); + expect(attr('settings-tab')).toBe('appearance'); + sessionStorage.setItem('settings-deep-link-tab', 'folders'); + boot('/en/settings'); + expect(attr('settings-tab')).toBe('organize'); + expect(attr('pane')).toBe('detail'); + }); +}); + +describe('boot script: pages that lead elsewhere', () => { + it.each([ + ['/connector/app', 'mail', null], + ['/connector/mail_folder?ref=inbox', 'mail', null], + ['/connector/contacts', 'contacts', null], + ['/connector/files?path=/Docs', 'files', null], + ['/connector/setup', 'setup', null], + ['/connector/admin?tab=logs', 'admin', 'detail'], + ['/connector/admin', 'admin', null], + ['/connector/admin_plugin?id=x', 'admin', 'detail'], + ])('%s draws %s', (path, surface, pane) => { + boot(path); + expect(attr('surface')).toBe(surface); + expect(attr('via')).toBe('connector'); + expect(attr('pane')).toBe(pane); + }); + + it('draws a connector calendar link in its view and a settings link on its page', () => { + boot('/connector/calendar?view=agenda&date=2026-11-04'); + expect(attr('surface')).toBe('calendar'); + expect(attr('calendar-view')).toBe('agenda'); + boot('/connector/calendar'); + expect(attr('calendar-view')).toBe('month'); + boot('/connector/settings?tab=templates'); + expect(attr('surface')).toBe('settings'); + expect(attr('settings-tab')).toBe('composing'); + }); + + it('leaves an unknown connector target without a surface', () => { + boot('/connector/nope'); + expect(attr('surface')).toBeNull(); + }); + + it('draws mail behind mailto and the calendar behind webcal', () => { + boot('/protocol/mailto?url=mailto%3Aa%40b.c'); + expect(attr('surface')).toBe('mail'); + expect(attr('via')).toBe('mailto'); + boot('/protocol/webcal?url=webcal%3A%2F%2Fx'); + expect(attr('surface')).toBe('calendar'); + expect(attr('via')).toBe('webcal'); + }); + + it.each([ + ['/admin', 'admin', null], + ['/admin/branding', 'admin', 'detail'], + ['/admin/login', 'admin-login', null], + ['/setup', 'setup', null], + ['/en/login', 'login', null], + ['/en/pro', 'pro', null], + ['/en/apps/plugin-x/page-y', 'apps', null], + ])('%s is %s', (path, surface, pane) => { + boot(path); + expect(attr('surface')).toBe(surface); + expect(attr('pane')).toBe(pane); + }); +}); + +describe('boot script: Pro interface', () => { + it('predicts the hop to /pro on a desktop with the Pro interface on', () => { + settings({ proInterface: true }); + boot('/en/mail/folder/inbox'); + expect(attr('pro')).toBe('on'); + boot('/en/settings/appearance'); + expect(attr('pro')).toBe('on'); + boot('/en/pro'); + expect(attr('pro')).toBe('on'); + }); + + it('not on a narrow screen, not without the setting, not for other pages', () => { + settings({ proInterface: true }); + desktop(false); + boot('/en/mail'); + expect(attr('pro')).toBeNull(); + desktop(true); + boot('/admin'); + expect(attr('pro')).toBeNull(); + settings({ proInterface: false }); + boot('/en/mail'); + expect(attr('pro')).toBeNull(); + }); +}); + +describe('boot script: signed-out visitors', () => { + it('marks a visitor with nothing stored as signed out on app pages', () => { + boot('/en/mail'); + expect(attr('auth')).toBe('out'); + boot('/en/calendar/week'); + expect(attr('auth')).toBe('out'); + }); + + it('not on the login, admin or setup pages', () => { + for (const path of ['/en/login', '/admin', '/admin/login', '/setup']) { + boot(path); + expect(attr('auth')).toBeNull(); + } + }); + + it('not with a persisted session, a remembered account, a demo account or an impersonation hand-off', () => { + signedIn(); + boot('/en/mail'); + expect(attr('auth')).toBeNull(); + localStorage.clear(); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a', rememberMe: true }] } })); + boot('/en/mail'); + expect(attr('auth')).toBeNull(); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'd', rememberMe: false, serverUrl: 'https://demo.example.com' }] } })); + boot('/en/mail'); + expect(attr('auth')).toBeNull(); + localStorage.clear(); + boot('/en/mail?impersonated=1'); + expect(attr('auth')).toBeNull(); + }); + + it('counts an account that will be dropped (no remember me) as signed out', () => { + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a', rememberMe: false }] } })); + boot('/en/mail'); + expect(attr('auth')).toBe('out'); + boot('/en/mail', { lite: true }); + expect(attr('auth')).toBeNull(); + }); +}); + +describe('boot script: rail hints', () => { + it('passes the stored rail items on, dropping anything odd', () => { + localStorage.setItem('bulwark-boot-hints', JSON.stringify({ v: 1, rail: ['mail', 'calendar', 'quota', '', 3] })); + boot('/en/mail'); + expect(attr('rail')).toBe('mail calendar quota'); + }); +}); + +describe('boot script: Lite', () => { + it('reads a parked deep link instead of the surface root it was replayed on', () => { + sessionStorage.setItem('bulwark-lite:pending-path', '/de/calendar/day/2026-11-04'); + boot('/de/calendar/', { lite: true }); + expect(attr('surface')).toBe('calendar'); + expect(attr('calendar-view')).toBe('day'); + }); + + it('ignores the parked path outside Lite', () => { + sessionStorage.setItem('bulwark-lite:pending-path', '/de/calendar/day/2026-11-04'); + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' } })); + boot('/de/calendar/'); + expect(attr('calendar-view')).toBe('week'); + }); + + it('strips the base path and the Lite mount', () => { + boot('/webmail/en/files/Docs', { basePath: '/webmail' }); + expect(attr('surface')).toBe('files'); + (window as unknown as Record).__BULWARK_LITE_MOUNT__ = '/bulwark'; + try { + boot('/bulwark/en/settings/filters', { lite: true }); + expect(attr('surface')).toBe('settings'); + expect(attr('settings-tab')).toBe('filters'); + } finally { + delete (window as unknown as Record).__BULWARK_LITE_MOUNT__; + } + }); +}); + +describe('boot script: cached theme', () => { + it('puts the cached theme and skin back before the page renders', () => { + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, css: ':root{--color-primary:red}', skin: 'html[data-theme-skin="t1"] body{font-family:x}', skinId: 't1' })); + boot('/en/mail'); + expect(document.getElementById('active-theme')?.textContent).toBe(':root{--color-primary:red}'); + expect(document.getElementById('active-theme-skin')?.textContent).toContain('font-family:x'); + expect(html().getAttribute('data-theme-skin')).toBe('t1'); + }); + + it('reuses existing elements instead of adding a second copy', () => { + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, css: 'a{}' })); + boot('/en/mail'); + boot('/en/mail'); + expect(document.querySelectorAll('#active-theme')).toHaveLength(1); + }); + + it('ignores a cache from another format version, or a skin without its id', () => { + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 2, css: 'a{}' })); + boot('/en/mail'); + expect(document.getElementById('active-theme')).toBeNull(); + localStorage.setItem(THEME_CACHE_KEY, JSON.stringify({ v: 1, skin: 'b{}' })); + boot('/en/mail'); + expect(document.getElementById('active-theme-skin')).toBeNull(); + }); +}); diff --git a/lib/boot-skeleton/__tests__/theme-cache.test.ts b/lib/boot-skeleton/__tests__/theme-cache.test.ts new file mode 100644 index 000000000..66c414eed --- /dev/null +++ b/lib/boot-skeleton/__tests__/theme-cache.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + bootSkinCSS, + injectThemeCSS, + injectThemeSkinCSS, + removeThemeCSS, + removeThemeSkinCSS, +} from '@/lib/theme-loader'; +import { THEME_CACHE_KEY, THEME_CACHE_MAX } from '../keys'; + +const cache = () => JSON.parse(localStorage.getItem(THEME_CACHE_KEY) || 'null'); + +beforeEach(() => { + localStorage.clear(); + removeThemeCSS(); + removeThemeSkinCSS(); + document.documentElement.removeAttribute('data-theme-skin'); +}); + +describe('theme cache for the boot script', () => { + it('keeps a copy of the injected colour block and skin', () => { + injectThemeCSS(':root{--color-primary:#f00}'); + injectThemeSkinCSS('body[data-theme-skin="t1"] .toolbar{gap:2px}', 't1'); + expect(cache()).toEqual({ + v: 1, + css: ':root{--color-primary:#f00}', + skin: 'html[data-theme-skin="t1"] body .toolbar{gap:2px}', + skinId: 't1', + }); + }); + + it('drops the parts that are removed, and the whole entry when nothing is left', () => { + injectThemeCSS('a{}'); + injectThemeSkinCSS('b{}', 't1'); + removeThemeSkinCSS(); + expect(cache()).toEqual({ v: 1, css: 'a{}' }); + removeThemeCSS(); + expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull(); + }); + + it('does not cache a theme too large for localStorage', () => { + injectThemeCSS('a{}'); + injectThemeCSS(`:root{--x:"${'y'.repeat(THEME_CACHE_MAX)}"}`); + expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull(); + expect(document.getElementById('active-theme')?.textContent).toContain('--x'); + }); + + it('clears the boot copy\'s attribute once the real skin is on ', () => { + document.documentElement.setAttribute('data-theme-skin', 't1'); + injectThemeSkinCSS('b{}', 't1'); + expect(document.body.getAttribute('data-theme-skin')).toBe('t1'); + expect(document.documentElement.hasAttribute('data-theme-skin')).toBe(false); + }); + + it('reuses a