@rapidmx/web-client 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -2
- package/apps/admin/_layout.tsx +8 -2
- package/apps/admin/index.tsx +7 -1
- package/apps/admin/ingest-queue/index.tsx +2 -1
- package/apps/admin/mailboxes/[uid].tsx +23 -10
- package/apps/admin/mailboxes/new/index.tsx +3 -3
- package/apps/admin/quarantine/index.tsx +2 -1
- package/apps/admin/signing-certificates/index.tsx +343 -0
- package/apps/escrow/_layout.tsx +8 -2
- package/apps/shared/appearance/AppearanceForm.tsx +392 -0
- package/apps/shared/appearance/AppearanceHead.tsx +49 -0
- package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
- package/apps/shared/appearance/ColorField.tsx +107 -0
- package/apps/shared/appearance/appearanceCache.ts +106 -0
- package/apps/shared/appearance/appearanceContext.ts +77 -0
- package/apps/shared/appearance/bootScript.ts +35 -0
- package/apps/shared/appearance/color.ts +169 -0
- package/apps/shared/appearance/photo.ts +88 -0
- package/apps/shared/appearance/resolvedTheme.ts +67 -0
- package/apps/shared/appearance/theme.ts +355 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
- package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
- package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +60 -14
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
- package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
- package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
- package/apps/shared/components/contacts/ContactForm.tsx +11 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
- package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
- package/apps/shared/components/layout/AppShell.tsx +98 -32
- package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
- package/apps/shared/components/layout/RailIcon.tsx +100 -0
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
- package/apps/shared/components/layout/UserMenu.tsx +75 -6
- package/apps/shared/components/mail/ConversationList.tsx +4 -2
- package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
- package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
- package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
- package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
- package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
- package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
- package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
- package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
- package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
- package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
- package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
- package/apps/shared/components/mail/reading/color.ts +245 -0
- package/apps/shared/components/mail/reading/frameControl.ts +142 -0
- package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
- package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
- package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
- package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
- package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +76 -0
- package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
- package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
- package/apps/shared/mail/folderCounts.ts +9 -3
- package/apps/shared/mail/folderTree.ts +143 -0
- package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
- package/apps/shared/mail/outbox/composeBridge.ts +69 -0
- package/apps/shared/mail/outbox/outboxState.ts +88 -0
- package/apps/shared/mail/outbox/pendingSends.ts +146 -0
- package/apps/shared/mail/outbox/sendDecision.ts +157 -0
- package/apps/shared/mail/outbox/sendJob.ts +427 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
- package/apps/shared/mail/outbox/sendState.ts +35 -0
- package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
- package/apps/shared/mail/useMailConnection.ts +81 -28
- package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
- package/apps/shared/mail/useNewMailNotifications.ts +47 -32
- package/apps/shared/navigation/AppRouter.tsx +2 -2
- package/apps/shared/notifications/NotificationCenter.tsx +215 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
- package/apps/shared/notifications/apiErrors.ts +98 -0
- package/apps/shared/notifications/headerOffset.ts +35 -0
- package/apps/shared/notifications/pushStatus.ts +56 -0
- package/apps/shared/notifications/store.ts +549 -0
- package/apps/shared/notifications/systemErrors.ts +63 -0
- package/apps/shared/notifications/useNotifications.ts +41 -0
- package/apps/shared/signing/enrollmentStorage.ts +33 -0
- package/apps/shared/signing/enrollmentTracker.ts +385 -0
- package/apps/shared/signing/enrollmentView.ts +251 -0
- package/apps/shared/signing/signingInfo.ts +58 -0
- package/apps/shared/signing/useNow.ts +19 -0
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
- package/apps/shared/styles/app.css +80 -0
- package/apps/www/_layout.tsx +8 -2
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +9 -14
- package/apps/www/contacts/[uid].tsx +5 -8
- package/apps/www/contacts/index.tsx +587 -584
- package/apps/www/index.tsx +38 -27
- package/apps/www/messages/[uid].tsx +3 -1
- package/apps/www/settings/appearance/index.tsx +21 -0
- package/apps/www/settings/auto-reply/index.tsx +3 -7
- package/apps/www/settings/encryption/index.tsx +151 -116
- package/apps/www/settings/filters/[uid].tsx +3 -1
- package/apps/www/settings/filters/new/index.tsx +3 -1
- package/apps/www/settings/privacy/index.tsx +3 -8
- package/apps/www/settings/read-receipts/index.tsx +3 -7
- package/apps/www/settings/sharing/index.tsx +44 -65
- package/apps/www/settings/signatures/[uid].tsx +3 -1
- package/apps/www/settings/signatures/new/index.tsx +3 -2
- package/apps/www/tasks/index.tsx +11 -16
- package/dist/apps/admin/_layout.d.ts +5 -1
- package/dist/apps/admin/_layout.js +3 -2
- package/dist/apps/admin/index.js +3 -2
- package/dist/apps/admin/ingest-queue/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +9 -6
- package/dist/apps/admin/mailboxes/new/index.js +1 -1
- package/dist/apps/admin/quarantine/index.js +2 -1
- package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
- package/dist/apps/admin/signing-certificates/index.js +161 -0
- package/dist/apps/escrow/_layout.d.ts +5 -1
- package/dist/apps/escrow/_layout.js +3 -2
- package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
- package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
- package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
- package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
- package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
- package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
- package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
- package/dist/apps/shared/appearance/ColorField.js +40 -0
- package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceCache.js +80 -0
- package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceContext.js +29 -0
- package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
- package/dist/apps/shared/appearance/bootScript.js +33 -0
- package/dist/apps/shared/appearance/color.d.ts +65 -0
- package/dist/apps/shared/appearance/color.js +132 -0
- package/dist/apps/shared/appearance/photo.d.ts +17 -0
- package/dist/apps/shared/appearance/photo.js +79 -0
- package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
- package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
- package/dist/apps/shared/appearance/theme.d.ts +111 -0
- package/dist/apps/shared/appearance/theme.js +251 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +25 -5
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
- package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
- package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
- package/dist/apps/shared/components/layout/AppShell.js +59 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
- package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
- package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
- package/dist/apps/shared/components/layout/RailIcon.js +85 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
- package/dist/apps/shared/components/layout/UserMenu.js +41 -7
- package/dist/apps/shared/components/mail/ConversationList.js +3 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
- package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
- package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
- package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
- package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
- package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
- package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
- package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
- package/dist/apps/shared/components/mail/reading/color.js +201 -0
- package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
- package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +28 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +24 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
- package/dist/apps/shared/mail/folderCounts.js +9 -3
- package/dist/apps/shared/mail/folderTree.d.ts +55 -0
- package/dist/apps/shared/mail/folderTree.js +116 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
- package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
- package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
- package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
- package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
- package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
- package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
- package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
- package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
- package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
- package/dist/apps/shared/mail/outbox/sendState.js +8 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
- package/dist/apps/shared/mail/useMailConnection.js +64 -22
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
- package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
- package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
- package/dist/apps/shared/navigation/AppRouter.js +2 -2
- package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
- package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
- package/dist/apps/shared/notifications/apiErrors.js +86 -0
- package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
- package/dist/apps/shared/notifications/headerOffset.js +33 -0
- package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
- package/dist/apps/shared/notifications/pushStatus.js +54 -0
- package/dist/apps/shared/notifications/store.d.ts +136 -0
- package/dist/apps/shared/notifications/store.js +416 -0
- package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
- package/dist/apps/shared/notifications/systemErrors.js +58 -0
- package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
- package/dist/apps/shared/notifications/useNotifications.js +18 -0
- package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
- package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
- package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
- package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
- package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
- package/dist/apps/shared/signing/enrollmentView.js +214 -0
- package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
- package/dist/apps/shared/signing/signingInfo.js +48 -0
- package/dist/apps/shared/signing/useNow.d.ts +2 -0
- package/dist/apps/shared/signing/useNow.js +18 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
- package/dist/apps/shared/styles/app.css +80 -0
- package/dist/apps/www/_layout.d.ts +5 -1
- package/dist/apps/www/_layout.js +3 -2
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +3 -4
- package/dist/apps/www/contacts/[uid].js +5 -7
- package/dist/apps/www/contacts/index.js +14 -21
- package/dist/apps/www/index.js +37 -19
- package/dist/apps/www/messages/[uid].js +3 -1
- package/dist/apps/www/settings/appearance/index.d.ts +4 -0
- package/dist/apps/www/settings/appearance/index.js +13 -0
- package/dist/apps/www/settings/auto-reply/index.js +3 -6
- package/dist/apps/www/settings/encryption/index.js +103 -95
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/new/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +5 -8
- package/dist/apps/www/settings/read-receipts/index.js +3 -6
- package/dist/apps/www/settings/sharing/index.js +19 -33
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/new/index.js +3 -2
- package/dist/apps/www/tasks/index.js +8 -14
- package/package.json +2 -2
- package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
- package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** A colour as the browser reports it: channels 0-255 and an alpha 0-1. */
|
|
7
|
+
export interface Rgba {
|
|
8
|
+
r: number;
|
|
9
|
+
g: number;
|
|
10
|
+
b: number;
|
|
11
|
+
a: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A colour with no transparency worth speaking of - what a contrast ratio is computed between. */
|
|
15
|
+
export type Rgb = Pick<Rgba, "r" | "g" | "b">;
|
|
16
|
+
|
|
17
|
+
/** The handful of named colours a `bgcolor`/`text` attribute is realistically written with. Anything else is not guessed at. */
|
|
18
|
+
const NAMED_COLOURS: Record<string, [number, number, number]> = {
|
|
19
|
+
white: [255, 255, 255],
|
|
20
|
+
black: [0, 0, 0],
|
|
21
|
+
red: [255, 0, 0],
|
|
22
|
+
green: [0, 128, 0],
|
|
23
|
+
blue: [0, 0, 255],
|
|
24
|
+
yellow: [255, 255, 0],
|
|
25
|
+
gray: [128, 128, 128],
|
|
26
|
+
grey: [128, 128, 128],
|
|
27
|
+
silver: [192, 192, 192],
|
|
28
|
+
navy: [0, 0, 128],
|
|
29
|
+
teal: [0, 128, 128],
|
|
30
|
+
maroon: [128, 0, 0],
|
|
31
|
+
purple: [128, 0, 128],
|
|
32
|
+
orange: [255, 165, 0],
|
|
33
|
+
lime: [0, 255, 0],
|
|
34
|
+
aqua: [0, 255, 255],
|
|
35
|
+
fuchsia: [255, 0, 255],
|
|
36
|
+
olive: [128, 128, 0],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function clamp(value: number, min: number, max: number): number {
|
|
40
|
+
return Math.min(max, Math.max(min, value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One `rgb()` channel: a number 0-255, or a percentage of it. */
|
|
44
|
+
function channel(token: string): number {
|
|
45
|
+
return token.endsWith("%") ? (parseFloat(token) / 100) * 255 : parseFloat(token);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An alpha: a number 0-1, or a percentage. */
|
|
49
|
+
function alpha(token: string | undefined): number {
|
|
50
|
+
if (token === undefined) {
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
return token.endsWith("%") ? parseFloat(token) / 100 : parseFloat(token);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads the colour notations a browser's computed style and an old-fashioned `bgcolor` attribute produce: `#rgb`, `#rgba`,
|
|
58
|
+
* `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()` in the comma and the space syntax, `color(srgb ...)`, `transparent` and a few names.
|
|
59
|
+
* `undefined` for anything else (a gradient, `currentcolor`, `oklch()`), which callers treat as "unknown, leave it alone".
|
|
60
|
+
*/
|
|
61
|
+
export function parseColour(input: string | undefined | null): Rgba | undefined {
|
|
62
|
+
const text = (input ?? "").trim().toLowerCase();
|
|
63
|
+
if (text === "transparent") {
|
|
64
|
+
return { r: 0, g: 0, b: 0, a: 0 };
|
|
65
|
+
}
|
|
66
|
+
if (text in NAMED_COLOURS) {
|
|
67
|
+
const [r, g, b] = NAMED_COLOURS[text];
|
|
68
|
+
return { r, g, b, a: 1 };
|
|
69
|
+
}
|
|
70
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)?.[1];
|
|
71
|
+
if (hex) {
|
|
72
|
+
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
|
73
|
+
const [r, g, b, a] = [0, 2, 4, 6].map((i) => (full.length > i ? parseInt(full.slice(i, i + 2), 16) : 255));
|
|
74
|
+
return { r, g, b, a: a / 255 };
|
|
75
|
+
}
|
|
76
|
+
const functional = /^(rgba?|color)\(\s*(?:srgb\s+)?([^)]*)\)$/.exec(text);
|
|
77
|
+
if (functional) {
|
|
78
|
+
const parts = functional[2].split(/[\s,/]+/).filter(Boolean);
|
|
79
|
+
if (parts.length < 3 || parts.length > 4 || parts.some((part) => !/^[-+]?(\d+\.?\d*|\.\d+)%?$/.test(part))) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const scale = functional[1] === "color" ? 255 : 1;
|
|
83
|
+
const [r, g, b] = parts.slice(0, 3).map((part) => clamp(channel(part) * scale, 0, 255));
|
|
84
|
+
return { r, g, b, a: clamp(alpha(parts[3]), 0, 1) };
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `rgb(r, g, b)` for an opaque colour, `rgba(...)` otherwise - what goes into a generated stylesheet. */
|
|
90
|
+
export function formatColour(colour: Rgba | Rgb): string {
|
|
91
|
+
const { r, g, b } = colour;
|
|
92
|
+
const a = "a" in colour ? colour.a : 1;
|
|
93
|
+
const rgb = [r, g, b].map((value) => Math.round(clamp(value, 0, 255))).join(", ");
|
|
94
|
+
return a >= 1 ? `rgb(${rgb})` : `rgba(${rgb}, ${Math.round(a * 1000) / 1000})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `top` painted over `bottom`, as the opaque colour that results. */
|
|
98
|
+
export function composite(top: Rgba, bottom: Rgb): Rgb {
|
|
99
|
+
return {
|
|
100
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
101
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
102
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function linear(value: number): number {
|
|
107
|
+
const c = value / 255;
|
|
108
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** WCAG relative luminance, 0 (black) to 1 (white). */
|
|
112
|
+
export function luminance(colour: Rgb): number {
|
|
113
|
+
return 0.2126 * linear(colour.r) + 0.7152 * linear(colour.g) + 0.0722 * linear(colour.b);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** WCAG contrast ratio between two opaque colours, 1 to 21. */
|
|
117
|
+
export function contrastRatio(a: Rgb, b: Rgb): number {
|
|
118
|
+
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
119
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The lowest contrast ratio WCAG accepts for body text. */
|
|
123
|
+
export const MIN_TEXT_CONTRAST = 4.5;
|
|
124
|
+
|
|
125
|
+
export interface Hsl {
|
|
126
|
+
h: number;
|
|
127
|
+
s: number;
|
|
128
|
+
l: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function rgbToHsl({ r, g, b }: Rgb): Hsl {
|
|
132
|
+
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
|
|
133
|
+
const max = Math.max(rn, gn, bn);
|
|
134
|
+
const min = Math.min(rn, gn, bn);
|
|
135
|
+
const l = (max + min) / 2;
|
|
136
|
+
if (max === min) {
|
|
137
|
+
return { h: 0, s: 0, l };
|
|
138
|
+
}
|
|
139
|
+
const d = max - min;
|
|
140
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
141
|
+
const h = max === rn ? (gn - bn) / d + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / d + 2 : (rn - gn) / d + 4;
|
|
142
|
+
return { h: h * 60, s, l };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function hslToRgb({ h, s, l }: Hsl): Rgb {
|
|
146
|
+
const a = s * Math.min(l, 1 - l);
|
|
147
|
+
const f = (n: number) => {
|
|
148
|
+
const k = (n + h / 30) % 12;
|
|
149
|
+
return 255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)));
|
|
150
|
+
};
|
|
151
|
+
return { r: f(0), g: f(8), b: f(4) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether two colours are the same to the eye's tolerance - what "unchanged" means when deciding to emit a rule. */
|
|
155
|
+
export function sameColour(a: Rgb, b: Rgb): boolean {
|
|
156
|
+
return Math.abs(a.r - b.r) < 1 && Math.abs(a.g - b.g) < 1 && Math.abs(a.b - b.b) < 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `colour` moved along its own hue and saturation - lighter over a dark background, darker over a light one - until it reaches
|
|
161
|
+
* `min` contrast against `background`. A colour that already has it is returned untouched; one that cannot get there (a mid-grey
|
|
162
|
+
* background) becomes whichever of white and black reads better.
|
|
163
|
+
*/
|
|
164
|
+
export function ensureContrast(colour: Rgb, background: Rgb, min = MIN_TEXT_CONTRAST): Rgb {
|
|
165
|
+
// Judged as the whole-number colour it will be written as: a fractional colour that reads can round to one that does not.
|
|
166
|
+
const whole = roundColour(colour);
|
|
167
|
+
if (contrastRatio(whole, background) >= min) {
|
|
168
|
+
return whole;
|
|
169
|
+
}
|
|
170
|
+
const target = luminance(background) < 0.5 ? 1 : 0;
|
|
171
|
+
const hsl = rgbToHsl(colour);
|
|
172
|
+
if (contrastRatio(hslToRgb({ ...hsl, l: target }), background) < min) {
|
|
173
|
+
return blackOrWhite(background);
|
|
174
|
+
}
|
|
175
|
+
let [near, far] = [hsl.l, target];
|
|
176
|
+
for (let i = 0; i < 16; i++) {
|
|
177
|
+
const middle = (near + far) / 2;
|
|
178
|
+
if (contrastRatio(roundColour(hslToRgb({ ...hsl, l: middle })), background) >= min) {
|
|
179
|
+
far = middle;
|
|
180
|
+
} else {
|
|
181
|
+
near = middle;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return roundColour(hslToRgb({ ...hsl, l: far }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function roundColour({ r, g, b }: Rgb): Rgb {
|
|
188
|
+
return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** How far from grey a colour may be and still count as a neutral (black, white, greys) rather than a colour with a hue to keep. */
|
|
192
|
+
const NEUTRAL_SATURATION = 0.12;
|
|
193
|
+
|
|
194
|
+
/** The theme a message is shown in: the opaque surface behind the body, its text and link colours, and whether it is a dark one. */
|
|
195
|
+
export interface ThemeSurface {
|
|
196
|
+
background: Rgb;
|
|
197
|
+
text: Rgb;
|
|
198
|
+
link: Rgb;
|
|
199
|
+
dark: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Whichever of black and white reads better on `background`. */
|
|
203
|
+
export function blackOrWhite(background: Rgb): Rgb {
|
|
204
|
+
const [white, black] = [
|
|
205
|
+
{ r: 255, g: 255, b: 255 },
|
|
206
|
+
{ r: 0, g: 0, b: 0 },
|
|
207
|
+
];
|
|
208
|
+
return contrastRatio(white, background) >= contrastRatio(black, background) ? white : black;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* An authored text colour that cannot be read against the theme's background, adapted so it can. A neutral (black, white, a
|
|
213
|
+
* grey) is flipped to the other end of the scale - black becomes the theme's text colour, a dark grey a light one, and in a
|
|
214
|
+
* light theme white becomes dark text - by placing it between the theme's text and background colours according to how far it
|
|
215
|
+
* is from the one it must not be. Any other colour keeps its hue and saturation and only has its lightness moved, as little as
|
|
216
|
+
* it takes to reach 4.5:1 (`ensureContrast()`), so navy stays a navy and a brand red stays red. One that already reads is
|
|
217
|
+
* returned untouched.
|
|
218
|
+
*/
|
|
219
|
+
export function adaptForeground(colour: Rgb, surface: ThemeSurface): Rgb {
|
|
220
|
+
if (contrastRatio(colour, surface.background) >= MIN_TEXT_CONTRAST) {
|
|
221
|
+
return colour;
|
|
222
|
+
}
|
|
223
|
+
const hsl = rgbToHsl(colour);
|
|
224
|
+
if (hsl.s >= NEUTRAL_SATURATION) {
|
|
225
|
+
return ensureContrast(colour, surface.background);
|
|
226
|
+
}
|
|
227
|
+
// The distance from the end of the scale that would have been readable: a dark theme wants light text, so it is how dark
|
|
228
|
+
// the colour is; a light theme, how light.
|
|
229
|
+
const t = surface.dark ? hsl.l : 1 - hsl.l;
|
|
230
|
+
const mixed = {
|
|
231
|
+
r: surface.text.r + (surface.background.r - surface.text.r) * t,
|
|
232
|
+
g: surface.text.g + (surface.background.g - surface.text.g) * t,
|
|
233
|
+
b: surface.text.b + (surface.background.b - surface.text.b) * t,
|
|
234
|
+
};
|
|
235
|
+
return ensureContrast(mixed, surface.background);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The first colour in a computed `background-image` (a gradient's first stop): the best guess at what text over a gradient sits
|
|
240
|
+
* on. `undefined` for an image (`url(...)`) or anything with no colour in it.
|
|
241
|
+
*/
|
|
242
|
+
export function firstColourStop(backgroundImage: string): Rgba | undefined {
|
|
243
|
+
const match = /rgba?\([^)]*\)|#[0-9a-f]{3,8}\b/i.exec(backgroundImage);
|
|
244
|
+
return match ? parseColour(match[0]) : undefined;
|
|
245
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { ThemeSurface } from "./color.js";
|
|
6
|
+
import { bodyOf, documentElementOf, viewOf } from "./safeDocument.js";
|
|
7
|
+
import { AdaptationResult, adaptDocument } from "./themeAdaptation.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The parent's side of a message's display frame: once its document has loaded, adapt its colours to the theme, then follow its height
|
|
11
|
+
* for as long as it lives. This is where the frame is read, which works because the frame is same-origin and has no script; every read
|
|
12
|
+
* is made through the DOM's own accessors on the frame's document (never by a name the document could have clobbered).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The tallest a message frame is allowed to grow, in CSS pixels: a hostile `height: 100000000px` must not build a layer the browser cannot paint. */
|
|
16
|
+
export const MAX_FRAME_HEIGHT = 200_000;
|
|
17
|
+
/** How many size changes one loaded document may report before the frame stops listening - a stylesheet that resizes itself in response to
|
|
18
|
+
* its own size (`height: 200vh`) would otherwise grow the frame on every observation, up to the cap. */
|
|
19
|
+
export const MAX_RESIZE_REPORTS = 200;
|
|
20
|
+
|
|
21
|
+
export interface FrameHandlers {
|
|
22
|
+
/** The document's content height in CSS pixels, whenever it changes (also once at load). */
|
|
23
|
+
onHeight: (height: number) => void;
|
|
24
|
+
/** The document is ready to show. `adaptation` is present when it was adapted to a theme. */
|
|
25
|
+
onReady: (adaptation?: AdaptationResult) => void;
|
|
26
|
+
/** Adapt to this surface before showing; absent for a document shown as it is. */
|
|
27
|
+
surface?: ThemeSurface;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pixels(value: string): number {
|
|
31
|
+
const parsed = parseFloat(value);
|
|
32
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The height of what the document displays: the bottom of its wrapper plus the body's own trailing margin, padding and border. Not
|
|
37
|
+
* `scrollHeight`, which is never less than the frame's own height, so a message could never shrink.
|
|
38
|
+
*/
|
|
39
|
+
export function measureFrameHeight(doc: Document): number {
|
|
40
|
+
const view = viewOf(doc);
|
|
41
|
+
const body = bodyOf(doc);
|
|
42
|
+
const wrapper = body?.firstElementChild;
|
|
43
|
+
if (!view || !body || !wrapper) {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const style = view.getComputedStyle(body);
|
|
47
|
+
const trailing = pixels(style.marginBottom) + pixels(style.paddingBottom) + pixels(style.borderBottomWidth);
|
|
48
|
+
const bottom = wrapper.getBoundingClientRect().bottom + view.scrollY + trailing;
|
|
49
|
+
return Math.min(MAX_FRAME_HEIGHT, Math.max(0, Math.ceil(bottom)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Re-sends a key pressed inside the frame to the app's own document, so the app's keyboard shortcuts work with focus in a message body
|
|
53
|
+
* (a frame keeps its own key events); if the app handled it, the frame's default action is prevented too. */
|
|
54
|
+
export function forwardKeyDown(event: KeyboardEvent, target: Document): void {
|
|
55
|
+
const copy = new KeyboardEvent("keydown", {
|
|
56
|
+
key: event.key,
|
|
57
|
+
code: event.code,
|
|
58
|
+
ctrlKey: event.ctrlKey,
|
|
59
|
+
shiftKey: event.shiftKey,
|
|
60
|
+
altKey: event.altKey,
|
|
61
|
+
metaKey: event.metaKey,
|
|
62
|
+
repeat: event.repeat,
|
|
63
|
+
bubbles: true,
|
|
64
|
+
cancelable: true,
|
|
65
|
+
});
|
|
66
|
+
if (!target.dispatchEvent(copy)) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Starts controlling `iframe`'s freshly loaded document. Returns a function that stops everything (observers, listeners, an adaptation
|
|
73
|
+
* still in progress); call it before the frame reloads and when it goes away. Returns a no-op for a frame with no readable document.
|
|
74
|
+
*/
|
|
75
|
+
export function controlFrame(iframe: HTMLIFrameElement, handlers: FrameHandlers): () => void {
|
|
76
|
+
const doc = iframe.contentDocument;
|
|
77
|
+
const view = doc ? viewOf(doc) : null;
|
|
78
|
+
const body = doc ? bodyOf(doc) : null;
|
|
79
|
+
const wrapper = body?.firstElementChild;
|
|
80
|
+
if (!doc || !view || !body || !wrapper) {
|
|
81
|
+
return () => undefined;
|
|
82
|
+
}
|
|
83
|
+
let disposed = false;
|
|
84
|
+
let reports = 0;
|
|
85
|
+
const measure = () => {
|
|
86
|
+
if (!disposed) {
|
|
87
|
+
handlers.onHeight(measureFrameHeight(doc));
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const onKeyDown = (event: KeyboardEvent) => forwardKeyDown(event, iframe.ownerDocument);
|
|
91
|
+
doc.addEventListener("keydown", onKeyDown);
|
|
92
|
+
|
|
93
|
+
measure();
|
|
94
|
+
// The frame's own ResizeObserver where it has one (the observations then run in its own rendering steps), else this window's.
|
|
95
|
+
const Observer: typeof ResizeObserver | undefined = view.ResizeObserver ?? (typeof ResizeObserver === "undefined" ? undefined : ResizeObserver);
|
|
96
|
+
let observer: ResizeObserver | undefined;
|
|
97
|
+
const onWindowResize = () => measure();
|
|
98
|
+
if (Observer) {
|
|
99
|
+
observer = new Observer(() => {
|
|
100
|
+
if (++reports > MAX_RESIZE_REPORTS) {
|
|
101
|
+
observer?.disconnect();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
measure();
|
|
105
|
+
});
|
|
106
|
+
for (const element of [wrapper, body, documentElementOf(doc)]) {
|
|
107
|
+
observer.observe(element);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
window.addEventListener("resize", onWindowResize);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Paper is not a dark surface: while the page is being printed the message goes back to the colours it was written with, and is adapted
|
|
114
|
+
// again afterwards.
|
|
115
|
+
let revert: (() => void) | undefined;
|
|
116
|
+
let reapply: (() => void) | undefined;
|
|
117
|
+
const onBeforePrint = () => revert?.();
|
|
118
|
+
const onAfterPrint = () => reapply?.();
|
|
119
|
+
window.addEventListener("beforeprint", onBeforePrint);
|
|
120
|
+
window.addEventListener("afterprint", onAfterPrint);
|
|
121
|
+
|
|
122
|
+
if (handlers.surface) {
|
|
123
|
+
void adaptDocument(doc, handlers.surface, { isCancelled: () => disposed }).then((result) => {
|
|
124
|
+
if (!disposed) {
|
|
125
|
+
({ revert, reapply } = result);
|
|
126
|
+
measure();
|
|
127
|
+
handlers.onReady(result);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
handlers.onReady();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
disposed = true;
|
|
136
|
+
window.removeEventListener("beforeprint", onBeforePrint);
|
|
137
|
+
window.removeEventListener("afterprint", onAfterPrint);
|
|
138
|
+
observer?.disconnect();
|
|
139
|
+
window.removeEventListener("resize", onWindowResize);
|
|
140
|
+
doc.removeEventListener("keydown", onKeyDown);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The document a message's body is displayed in: the `srcdoc` of a sandboxed iframe.
|
|
8
|
+
*
|
|
9
|
+
* **Why an iframe, and why `allow-same-origin`.** The body is untrusted HTML. It must never be inserted into the app's own DOM
|
|
10
|
+
* (a sanitizer bug there is script in the app's origin, next to the unlocked mail keys), so it lives in a frame. A frame with
|
|
11
|
+
* `sandbox=""` has an opaque origin and cannot be measured from outside, which is what made every message a full-height scrolling
|
|
12
|
+
* box. `allow-same-origin` (and nothing else that runs code - never `allow-scripts`) lets the *app* read the document's height and
|
|
13
|
+
* adapt its colours, and is safe because a document with no script can neither use the origin nor reach the app: there is no code in
|
|
14
|
+
* the frame to do it. That claim rests on four layers, each enough to stop a script on its own (server sanitizer, `bodyHtml.ts`,
|
|
15
|
+
* the CSP below, the missing `allow-scripts`), and is proven against a corpus of hostile mail in a real browser
|
|
16
|
+
* (`.claude/NOTES.md`, 2026-09-21).
|
|
17
|
+
*
|
|
18
|
+
* The rest of the sandbox: `allow-popups` and `allow-popups-to-escape-sandbox` only so a link the reader clicks can open in a new tab
|
|
19
|
+
* (every link is `target=_blank rel="noopener noreferrer nofollow"`, and no script can open one uninvited); no `allow-forms`,
|
|
20
|
+
* `allow-top-navigation`, `allow-modals`, `allow-downloads`, `allow-pointer-lock` or `allow-presentation`.
|
|
21
|
+
*/
|
|
22
|
+
export const FRAME_SANDBOX = "allow-same-origin allow-popups allow-popups-to-escape-sandbox";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The frame's Content-Security-Policy. Nothing is loaded from anywhere: images are `data:` URIs and, for a message's inline parts,
|
|
26
|
+
* this server's own attachment URLs (`attachmentSource`); remote images, fonts, stylesheets, frames and every other fetch are
|
|
27
|
+
* refused, as they are today by the server's own header for `/content` (`default-src 'none'; img-src data: cid:; style-src
|
|
28
|
+
* 'unsafe-inline'; sandbox`). Inline styles are allowed because mail is styled that way; nothing else inline is (no script at all).
|
|
29
|
+
*/
|
|
30
|
+
export function frameCsp(attachmentSource?: string): string {
|
|
31
|
+
// A source is a URL prefix (`https://host/api/mail/attachments/`); anything that is not one is left out rather than trusted, as this
|
|
32
|
+
// string is written into an attribute.
|
|
33
|
+
const source = attachmentSource && /^https?:\/\/[\w.:@-]+(?:\/[\w./%~-]*)?$/.test(attachmentSource) ? attachmentSource : undefined;
|
|
34
|
+
return [
|
|
35
|
+
"default-src 'none'",
|
|
36
|
+
"script-src 'none'",
|
|
37
|
+
"object-src 'none'",
|
|
38
|
+
"frame-src 'none'",
|
|
39
|
+
"form-action 'none'",
|
|
40
|
+
"base-uri 'none'",
|
|
41
|
+
"style-src 'unsafe-inline'",
|
|
42
|
+
`img-src data:${source ? ` ${source}` : ""}`,
|
|
43
|
+
"font-src data:",
|
|
44
|
+
].join("; ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How a message is being shown: as the theme dictates (`adapt`), as its author wrote it (`original`), or - a message that carries its
|
|
48
|
+
* own dark styles - as those styles say (`native`). */
|
|
49
|
+
export type FrameMode = "adapt" | "original" | "native";
|
|
50
|
+
|
|
51
|
+
export interface FrameOptions {
|
|
52
|
+
mode: FrameMode;
|
|
53
|
+
/** The app's scheme: what `prefers-color-scheme` means inside the frame (it would otherwise be the operating system's). */
|
|
54
|
+
scheme: "light" | "dark";
|
|
55
|
+
/** The theme's opaque body surface and text colour, as `rgb()` - the defaults a native-dark message starts from. */
|
|
56
|
+
surface: string;
|
|
57
|
+
text: string;
|
|
58
|
+
/** The CSP `img-src` source for this server's attachment URLs, when the message has inline images that point there. */
|
|
59
|
+
attachmentSource?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The colour the adaptation pass reads to tell text nobody coloured from text somebody did (`themeAdaptation.ts`). */
|
|
63
|
+
export const TEXT_SENTINEL = "rgb(1, 2, 3)";
|
|
64
|
+
/** The same for a link nobody coloured. */
|
|
65
|
+
export const LINK_SENTINEL = "rgb(1, 2, 4)";
|
|
66
|
+
|
|
67
|
+
const ALWAYS_TRUE = "(min-width: 0px)";
|
|
68
|
+
const ALWAYS_FALSE = "(max-width: -1px)";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Makes `prefers-color-scheme` mean the app's scheme inside a message's stylesheets. A browser answers it with the operating
|
|
72
|
+
* system's preference, which is not what the reader chose in Appearance; each occurrence becomes a media condition that is always
|
|
73
|
+
* true or always false. Only `<style>` contents are rewritten.
|
|
74
|
+
*/
|
|
75
|
+
export function rewriteColourSchemeQueries(html: string, scheme: "light" | "dark"): string {
|
|
76
|
+
return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_match, open: string, css: string, close: string) => {
|
|
77
|
+
const rewritten = css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_condition, wanted: string) =>
|
|
78
|
+
wanted.toLowerCase() === scheme ? ALWAYS_TRUE : ALWAYS_FALSE,
|
|
79
|
+
);
|
|
80
|
+
return open + rewritten + close;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Layout rules every mode shares: the document is exactly as tall as its content, never scrolls itself, and a wide table scrolls
|
|
85
|
+
* inside the message instead of widening the pane. */
|
|
86
|
+
const LAYOUT_CSS = `
|
|
87
|
+
html{height:auto!important;min-height:0!important;overflow:hidden!important;font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
|
|
88
|
+
body{height:auto!important;min-height:0!important}
|
|
89
|
+
#rr-body{box-sizing:border-box;position:relative;overflow-x:auto;overflow-y:hidden;overflow-wrap:break-word}
|
|
90
|
+
#rr-body img{max-width:100%;height:auto}
|
|
91
|
+
#rr-body pre{white-space:pre-wrap}
|
|
92
|
+
@media print{html{overflow:visible!important}}
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
function modeCss(options: FrameOptions): string {
|
|
96
|
+
const { mode, scheme, surface, text } = options;
|
|
97
|
+
if (mode === "adapt") {
|
|
98
|
+
// The sentinels are what the adaptation pass reads to tell what a message's author coloured from what it inherited; the pass
|
|
99
|
+
// then replaces them, so they never show. Zero specificity: any rule of the message beats them.
|
|
100
|
+
return `:where(html){color-scheme:${scheme}}:where(body){margin:0}:where(:root){color:${TEXT_SENTINEL}}:where(a:link,a:visited){color:${LINK_SENTINEL}}`;
|
|
101
|
+
}
|
|
102
|
+
if (mode === "native") {
|
|
103
|
+
return `:where(html){color-scheme:${scheme};background-color:${surface};color:${text}}:where(body){margin:0}`;
|
|
104
|
+
}
|
|
105
|
+
// As its author wrote it: a light document, as a browser in a light scheme shows it - with the browser's own 8px page margin. Zero
|
|
106
|
+
// specificity, so the message's own rules win.
|
|
107
|
+
return ":where(html){color-scheme:light}:where(body){margin:8px}";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The complete document for a message: the CSP as the very first element of the head, the layout rules, the mode's defaults and the
|
|
112
|
+
* sanitized fragment inside `#rr-body`. `fragment` must come out of `prepareBodyHtml()`.
|
|
113
|
+
*/
|
|
114
|
+
export function buildFrameDocument(fragment: string, options: FrameOptions): string {
|
|
115
|
+
const body = rewriteColourSchemeQueries(fragment, options.mode === "original" ? "light" : options.scheme);
|
|
116
|
+
return [
|
|
117
|
+
"<!doctype html>",
|
|
118
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
119
|
+
`<meta http-equiv="Content-Security-Policy" content="${frameCsp(options.attachmentSource)}">`,
|
|
120
|
+
`<style>${LAYOUT_CSS}${modeCss(options)}</style></head>`,
|
|
121
|
+
`<body><div id="rr-body">${body}</div></body></html>`,
|
|
122
|
+
].join("");
|
|
123
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads off a message's display document that a hostile message cannot redirect. On a `Document` the named properties of `<img name=...>`,
|
|
8
|
+
* `<form name=...>` and friends *override* the built-in ones (`<img name="body">` makes `document.body` that image), so anything reading
|
|
9
|
+
* `doc.body` from the app would be handed the message's element. Every such element and attribute is stripped before display
|
|
10
|
+
* (`bodyHtml.ts`), and these accessors - the platform's own getters, taken from this window's `Document.prototype` and applied to the
|
|
11
|
+
* frame's document - are the second guard: they answer with the real thing whatever the document contains.
|
|
12
|
+
*/
|
|
13
|
+
type DocumentGetter = "documentElement" | "body" | "defaultView";
|
|
14
|
+
|
|
15
|
+
const getters: Partial<Record<DocumentGetter, () => unknown>> = {};
|
|
16
|
+
|
|
17
|
+
function read<T>(doc: Document, name: DocumentGetter): T {
|
|
18
|
+
getters[name] ??= Object.getOwnPropertyDescriptor(Document.prototype, name)!.get as () => unknown;
|
|
19
|
+
return getters[name].call(doc) as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const documentElementOf = (doc: Document): HTMLElement => read<HTMLElement>(doc, "documentElement");
|
|
23
|
+
export const bodyOf = (doc: Document): HTMLElement | null => read<HTMLElement | null>(doc, "body");
|
|
24
|
+
export const viewOf = (doc: Document): (Window & typeof globalThis) | null => read<(Window & typeof globalThis) | null>(doc, "defaultView");
|