diff --git a/.changeset/improve-sable-theme-management.md b/.changeset/improve-sable-theme-management.md
new file mode 100644
index 0000000000..0133afd83b
--- /dev/null
+++ b/.changeset/improve-sable-theme-management.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Add uploaded Sable CSS theme loading, automatic catalog updates, and a cleaner theme browser with full CSS viewing.
diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx
index 666beab506..5ce02d13d2 100644
--- a/src/app/components/RenderMessageContent.test.tsx
+++ b/src/app/components/RenderMessageContent.test.tsx
@@ -2,8 +2,19 @@ import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MsgType } from '$types/matrix-sdk';
import { ClientConfigProvider } from '$hooks/useClientConfig';
+import { MatrixClientProvider } from '$hooks/useMatrixClient';
import { RenderMessageContent } from './RenderMessageContent';
+vi.mock('./message/content/UploadedSableCssContent', () => ({
+ UploadedSableCssContent: ({ body }: { body: string }) => (
+
{body}
+ ),
+}));
+
+vi.mock('$hooks/useMediaAuthentication', () => ({
+ useMediaAuthentication: () => false,
+}));
+
vi.mock('./url-preview', () => ({
UrlPreviewHolder: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -30,6 +41,23 @@ function renderMessage(body: string) {
);
}
+function renderFileMessage(content: Record) {
+ return render(
+
+
+ content as never}
+ htmlReactParserOptions={{}}
+ linkifyOpts={{}}
+ />
+
+
+ );
+}
+
beforeEach(() => {
vi.stubGlobal('location', { origin: 'https://app.example' } as Location);
});
@@ -92,4 +120,16 @@ describe('RenderMessageContent', () => {
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com)');
});
+
+ it('detects an uploaded Sable theme by filename when the body is a caption', () => {
+ renderFileMessage({
+ msgtype: MsgType.File,
+ body: 'A theme you might like',
+ filename: 'amethyst.sable.css',
+ url: 'mxc://example/amethyst',
+ info: { mimetype: 'text/css', size: 1024 },
+ });
+
+ expect(screen.getByTestId('uploaded-sable-css')).toHaveTextContent('amethyst.sable.css');
+ });
});
diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
index 57c607eac1..5c5538a96c 100644
--- a/src/app/components/RenderMessageContent.tsx
+++ b/src/app/components/RenderMessageContent.tsx
@@ -31,6 +31,7 @@ import {
RenderBody,
ThumbnailContent,
UnsupportedContent,
+ UploadedSableCssContent,
VideoContent,
} from './message';
import {
@@ -42,6 +43,7 @@ import {
youtubeUrl,
} from './url-preview';
import { isHttpsFullSableCssUrl } from '../theme/previewUrls';
+import { isSableCssAttachmentFileName } from '../theme/processThemeImport';
import { Image, MediaControl, PersistedVolumeVideo } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
@@ -278,13 +280,13 @@ function RenderMessageContentInternal({
renderCaptionedAttachment(
& { msgtype: MsgType.File }}
- renderFileContent={({ body, mimeType, info, encInfo, url }) => (
+ renderFileContent={({ fileName, mimeType, info, encInfo, url }) => (
(
(
)}
>
-
+ {themeChatSableWidgets && isSableCssAttachmentFileName(fileName) && (
+
+ )}
+
)}
outlined={outlineAttachment}
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index 135aa66cde..982548d6f6 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -652,7 +652,7 @@ export function MAudio({
}
type RenderFileContentProps = {
- body: string;
+ fileName: string;
info: IFileInfo & IThumbnailContent;
mimeType: string;
url: string;
@@ -682,7 +682,7 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
{renderFileContent({
- body: filename,
+ fileName: filename,
info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl,
diff --git a/src/app/components/message/content/UploadedSableCssContent.tsx b/src/app/components/message/content/UploadedSableCssContent.tsx
new file mode 100644
index 0000000000..c3486000e7
--- /dev/null
+++ b/src/app/components/message/content/UploadedSableCssContent.tsx
@@ -0,0 +1,380 @@
+import { useCallback, useMemo } from 'react';
+import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { Box, Button, IconButton, Spinner, Switch, Text, config, toRem } from 'folds';
+
+import { Star, sizedIcon } from '$components/icons/phosphor';
+import { ThemePreviewCard } from '$components/theme/ThemePreviewCard';
+import { CssViewerButton } from '$components/theme/CssViewerButton';
+import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
+import { useClientConfig } from '$hooks/useClientConfig';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useSetting } from '$state/hooks/settings';
+import {
+ settingsAtom,
+ type Settings,
+ type ThemeRemoteFavorite,
+ type ThemeRemoteTweakFavorite,
+} from '$state/settings';
+import {
+ processUploadedSableCssAttachment,
+ type ProcessedUploadedSableCss,
+} from '../../../theme/processThemeImport';
+import { isThirdPartyThemeUrl } from '../../../theme/themeApproval';
+import {
+ buildPreviewStyleBlock,
+ extractSafePreviewCustomProperties,
+} from '../../../theme/previewCss';
+import {
+ pruneThemeFavorites,
+ pruneThemeTweakFavorites,
+ themeSourceLabel,
+} from '../../../theme/themeLibrary';
+import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
+
+export const MAX_SABLE_CSS_ATTACHMENT_BYTES = 1024 * 1024;
+
+type UploadedSableCssContentProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ size?: number;
+ encInfo?: EncryptedAttachmentInfo;
+};
+
+type SuccessfulUpload = Extract;
+
+function UploadedTweakCard({ data }: { data: Extract }) {
+ const patchSettings = usePatchSettings();
+ const [favorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites');
+ const [enabledUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls');
+ const isFavorite = favorites.some((favorite) => favorite.fullUrl === data.fullUrl);
+ const isEnabled = enabledUrls.includes(data.fullUrl);
+ const scopeClass = useMemo(
+ () => `sable-uploaded-tweak--${data.basename.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
+ [data.basename]
+ );
+ const styleBlock = useMemo(
+ () => buildPreviewStyleBlock(extractSafePreviewCustomProperties(data.cssText), scopeClass),
+ [data.cssText, scopeClass]
+ );
+
+ const makeFavorite = useCallback(
+ (pinned: boolean): ThemeRemoteTweakFavorite[] => {
+ const existing = favorites.find((favorite) => favorite.fullUrl === data.fullUrl);
+ if (existing) {
+ return favorites.map((favorite) =>
+ favorite.fullUrl === data.fullUrl && pinned ? { ...favorite, pinned: true } : favorite
+ );
+ }
+ return [
+ ...favorites,
+ {
+ fullUrl: data.fullUrl,
+ displayName: data.displayName,
+ basename: data.basename,
+ pinned,
+ importedLocal: true,
+ },
+ ];
+ },
+ [data, favorites]
+ );
+
+ const toggleFavorite = useCallback(() => {
+ if (isFavorite) {
+ const nextEnabled = enabledUrls.filter((url) => url !== data.fullUrl);
+ patchSettings({
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(
+ favorites.filter((favorite) => favorite.fullUrl !== data.fullUrl),
+ nextEnabled
+ ),
+ });
+ return;
+ }
+ patchSettings({ themeRemoteTweakFavorites: makeFavorite(true) });
+ }, [data.fullUrl, enabledUrls, favorites, isFavorite, makeFavorite, patchSettings]);
+
+ const setEnabled = useCallback(
+ (enabled: boolean) => {
+ const nextEnabled = enabled
+ ? Array.from(new Set([...enabledUrls, data.fullUrl]))
+ : enabledUrls.filter((url) => url !== data.fullUrl);
+ patchSettings({
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(makeFavorite(false), nextEnabled),
+ });
+ },
+ [data.fullUrl, enabledUrls, makeFavorite, patchSettings]
+ );
+
+ const description = [
+ data.description,
+ data.author ? `by ${data.author}` : undefined,
+ data.tags.length > 0 ? data.tags.join(', ') : undefined,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+
+ return (
+
+
+
+ {data.displayName}
+
+ {description || 'Uploaded tweak'}
+
+
+ Source: Uploaded file
+
+
+
+
+
+ {sizedIcon(Star, '200', { filled: isFavorite })}
+
+
+
+
+ {styleBlock && (
+ <>
+
+
+
+ Sample text
+
+
+ >
+ )}
+
+ );
+}
+
+function UploadedThemeCard({ data }: { data: Extract }) {
+ const clientConfig = useClientConfig();
+ const patchSettings = usePatchSettings();
+ const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites');
+ const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
+ const [manualUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl');
+ const [lightUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl');
+ const [darkUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl');
+ const fullUrl = data.fullUrl;
+ const isFavorite = fullUrl ? favorites.some((favorite) => favorite.fullUrl === fullUrl) : false;
+
+ const ensureFavorite = useCallback(
+ (pinned: boolean): ThemeRemoteFavorite[] => {
+ if (!fullUrl) return favorites;
+ const existing = favorites.find((favorite) => favorite.fullUrl === fullUrl);
+ if (existing) {
+ return favorites.map((favorite) =>
+ favorite.fullUrl === fullUrl && pinned ? { ...favorite, pinned: true } : favorite
+ );
+ }
+ return [
+ ...favorites,
+ {
+ fullUrl,
+ displayName: data.displayName,
+ basename: data.basename,
+ kind: data.kind,
+ pinned,
+ importedLocal: data.importedLocal,
+ },
+ ];
+ },
+ [data, favorites, fullUrl]
+ );
+
+ const toggleFavorite = useCallback(() => {
+ if (!fullUrl) return;
+ if (!isFavorite) {
+ patchSettings({ themeRemoteFavorites: ensureFavorite(true) });
+ return;
+ }
+ const partial: Partial = {};
+ if (manualUrl === fullUrl) {
+ partial.themeRemoteManualFullUrl = undefined;
+ partial.themeRemoteManualKind = undefined;
+ }
+ if (lightUrl === fullUrl) {
+ partial.themeRemoteLightFullUrl = undefined;
+ partial.themeRemoteLightKind = undefined;
+ }
+ if (darkUrl === fullUrl) {
+ partial.themeRemoteDarkFullUrl = undefined;
+ partial.themeRemoteDarkKind = undefined;
+ }
+ patchSettings({
+ ...partial,
+ themeRemoteFavorites: pruneThemeFavorites(
+ favorites.filter((favorite) => favorite.fullUrl !== fullUrl),
+ [
+ manualUrl === fullUrl ? undefined : manualUrl,
+ lightUrl === fullUrl ? undefined : lightUrl,
+ darkUrl === fullUrl ? undefined : darkUrl,
+ ]
+ ),
+ });
+ }, [darkUrl, ensureFavorite, favorites, fullUrl, isFavorite, lightUrl, manualUrl, patchSettings]);
+
+ const apply = useCallback(
+ (slot: 'manual' | 'light' | 'dark') => {
+ if (!fullUrl) return;
+ const partial: Partial = {
+ themeRemoteFavorites: pruneThemeFavorites(ensureFavorite(false), [
+ slot === 'manual' ? fullUrl : manualUrl,
+ slot === 'light' ? fullUrl : lightUrl,
+ slot === 'dark' ? fullUrl : darkUrl,
+ ]),
+ };
+ if (slot === 'manual') {
+ partial.themeRemoteManualFullUrl = fullUrl;
+ partial.themeRemoteManualKind = data.kind;
+ } else if (slot === 'light') {
+ partial.themeRemoteLightFullUrl = fullUrl;
+ partial.themeRemoteLightKind = data.kind;
+ } else {
+ partial.themeRemoteDarkFullUrl = fullUrl;
+ partial.themeRemoteDarkKind = data.kind;
+ }
+ patchSettings(partial);
+ },
+ [darkUrl, data.kind, ensureFavorite, fullUrl, lightUrl, manualUrl, patchSettings]
+ );
+
+ const kindLabel = data.kind === 'dark' ? 'Dark' : 'Light';
+ const subtitle = data.previewOnly
+ ? `${kindLabel} · Uploaded preview only — no reachable full theme was provided.`
+ : `${kindLabel} · ${data.importedLocal ? 'Uploaded theme' : 'Uploaded preview'}`;
+
+ return (
+ apply('light') : undefined}
+ onApplyDark={systemTheme && fullUrl ? () => apply('dark') : undefined}
+ onApplyManual={!systemTheme && fullUrl ? () => apply('manual') : undefined}
+ isAppliedLight={Boolean(fullUrl && lightUrl === fullUrl)}
+ isAppliedDark={Boolean(fullUrl && darkUrl === fullUrl)}
+ isAppliedManual={Boolean(fullUrl && manualUrl === fullUrl)}
+ />
+ );
+}
+
+export function UploadedSableCssContent({
+ body,
+ mimeType,
+ url,
+ size,
+ encInfo,
+}: UploadedSableCssContentProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+
+ const [loadState, load] = useAsyncCallback(
+ useCallback(async (): Promise => {
+ if (typeof size === 'number' && size > MAX_SABLE_CSS_ATTACHMENT_BYTES) {
+ throw new Error('Theme CSS attachments must be 1 MiB or smaller.');
+ }
+ const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
+ if (!mediaUrl) throw new Error('Invalid media URL.');
+ const blob = encInfo
+ ? await downloadEncryptedMedia(mediaUrl, (encrypted) =>
+ decryptFile(encrypted, mimeType, encInfo)
+ )
+ : await downloadMedia(mediaUrl);
+ if (blob.size > MAX_SABLE_CSS_ATTACHMENT_BYTES) {
+ throw new Error('Theme CSS attachments must be 1 MiB or smaller.');
+ }
+ const result = await processUploadedSableCssAttachment(
+ await blob.text(),
+ body,
+ `${url}\n${JSON.stringify(encInfo ?? null)}`
+ );
+ if (!result.ok) throw new Error(result.error);
+ return result;
+ }, [body, encInfo, mimeType, mx, size, url, useAuthentication])
+ );
+
+ if (loadState.status === AsyncStatus.Success) {
+ return loadState.data.role === 'theme' ? (
+
+ ) : (
+
+ );
+ }
+
+ return (
+
+
+ ) : undefined
+ }
+ onClick={() => load()}
+ >
+
+ {loadState.status === AsyncStatus.Loading
+ ? 'Loading theme preview…'
+ : loadState.status === AsyncStatus.Error
+ ? 'Retry theme preview'
+ : 'Load theme preview'}
+
+
+ {loadState.status === AsyncStatus.Error && (
+
+ {loadState.error instanceof Error ? loadState.error.message : 'Failed to load theme CSS.'}
+
+ )}
+
+ );
+}
diff --git a/src/app/components/message/content/index.ts b/src/app/components/message/content/index.ts
index 6a31ed7ee0..ca28c20052 100644
--- a/src/app/components/message/content/index.ts
+++ b/src/app/components/message/content/index.ts
@@ -3,5 +3,6 @@ export * from './ImageContent';
export * from './VideoContent';
export * from './AudioContent';
export * from './FileContent';
+export * from './UploadedSableCssContent';
export * from './FallbackContent';
export * from './EventContent';
diff --git a/src/app/components/theme/CssViewerButton.tsx b/src/app/components/theme/CssViewerButton.tsx
new file mode 100644
index 0000000000..fff20ddaec
--- /dev/null
+++ b/src/app/components/theme/CssViewerButton.tsx
@@ -0,0 +1,83 @@
+import { useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import { IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
+
+import { Code, sizedIcon } from '$components/icons/phosphor';
+import { TextViewer } from '$components/text-viewer';
+import { ModalWide } from '$styles/Modal.css';
+import { stopPropagation } from '$utils/keyboard';
+
+type CssViewerButtonProps = {
+ title: string;
+ cssText?: string;
+ loadCssText?: () => Promise;
+ ariaLabel: string;
+};
+
+export function CssViewerButton({ title, cssText, loadCssText, ariaLabel }: CssViewerButtonProps) {
+ const [open, setOpen] = useState(false);
+ const [loadedCssText, setLoadedCssText] = useState('');
+ const [loading, setLoading] = useState(false);
+ const viewerText = cssText?.trim() ? cssText : loadedCssText;
+
+ if (!cssText?.trim() && !loadCssText) return null;
+
+ const handleOpen = async () => {
+ if (viewerText) {
+ setOpen(true);
+ return;
+ }
+ if (!loadCssText || loading) return;
+ setLoading(true);
+ try {
+ const loaded = await loadCssText();
+ if (!loaded.trim()) return;
+ setLoadedCssText(loaded);
+ setOpen(true);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+ {
+ handleOpen().catch(() => undefined);
+ }}
+ >
+ {sizedIcon(Code, '200')}
+
+ {open && (
+ }>
+
+ setOpen(false),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+ setOpen(false)}
+ />
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/app/components/theme/ThemePreviewCard.tsx b/src/app/components/theme/ThemePreviewCard.tsx
index 049ef33932..eb47b273bf 100644
--- a/src/app/components/theme/ThemePreviewCard.tsx
+++ b/src/app/components/theme/ThemePreviewCard.tsx
@@ -5,13 +5,17 @@ import { Check, Download, Link, Star, Warning, sizedIcon } from '$components/ico
import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
import { copyToClipboard } from '$utils/dom';
import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../../theme/previewCss';
+import { CssViewerButton } from './CssViewerButton';
export type ThemePreviewCardProps = {
title: string;
subtitle?: ReactNode;
beforePreview?: ReactNode;
previewCssText: string;
+ fullCssText?: string;
+ loadFullCssText?: () => Promise;
scopeSlug: string;
+ sourceLabel?: string;
copyText?: string;
isFavorited?: boolean;
onToggleFavorite?: () => void | Promise;
@@ -40,7 +44,10 @@ export function ThemePreviewCard({
subtitle,
beforePreview,
previewCssText,
+ fullCssText,
+ loadFullCssText,
scopeSlug,
+ sourceLabel,
copyText,
isFavorited,
onToggleFavorite,
@@ -119,9 +126,20 @@ export function ThemePreviewCard({
{subtitle}
)}
+ {sourceLabel && (
+
+ Source: {sourceLabel}
+
+ )}
+
{copyText && (
{
- const active = new Set(nextActive);
- return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl));
- },
- []
- );
-
const toggleFavorite = useCallback(async () => {
const fullUrl = previewQuery.data?.fullUrl;
if (!fullUrl) return;
@@ -173,7 +157,10 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
const nextActive = [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl]
.filter((u): u is string => Boolean(u && u.trim().length > 0))
.filter((u) => u !== fullUrl);
- patchSettings({ ...cleared, themeRemoteFavorites: pruneFavorites(nextFavs, nextActive) });
+ patchSettings({
+ ...cleared,
+ themeRemoteFavorites: pruneThemeFavorites(nextFavs, nextActive),
+ });
return;
}
@@ -205,7 +192,6 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
manualRemoteFullUrl,
patchSettings,
previewQuery.data,
- pruneFavorites,
]);
const applyManual = useCallback(() => {
@@ -304,7 +290,7 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
].filter((u): u is string => Boolean(u && u.trim().length > 0));
patchSettings({
- themeRemoteFavorites: pruneFavorites(
+ themeRemoteFavorites: pruneThemeFavorites(
store.get(settingsAtom).themeRemoteFavorites,
nextActive
),
@@ -318,7 +304,6 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
manualRemoteFullUrl,
patchSettings,
previewQuery.data?.fullUrl,
- pruneFavorites,
store,
]
);
@@ -336,7 +321,7 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
after.themeRemoteDarkFullUrl,
].filter((u): u is string => Boolean(u && u.trim().length > 0));
patchSettings({
- themeRemoteFavorites: pruneFavorites(favs, nextActive),
+ themeRemoteFavorites: pruneThemeFavorites(favs, nextActive),
});
};
@@ -377,7 +362,6 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
patchSettings,
previewQuery.data?.fullUrl,
previewQuery.data?.kind,
- pruneFavorites,
store,
]);
@@ -388,7 +372,7 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
{
setUserTriggeredLoad(true);
@@ -407,10 +391,9 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
previewQuery.data?.tags && previewQuery.data.tags.length > 0
? previewQuery.data.tags.join(', ')
: '';
- const sourceLabel = isOfficial ? 'Official theme' : baseLabel(url);
+ const sourceLabel = isOfficial ? 'Official catalog' : themeUrlHostLabel(url, 'Unofficial theme');
const subtitleLine1 = [kindLabel, contrastLabel].filter(Boolean).join(' · ');
const subtitleLine2 = [authorLabel, tagsLabel].filter(Boolean).join(' · ');
- const subtitleLine3 = sourceLabel;
const subtitle = (
<>
{subtitleLine1}
@@ -420,11 +403,19 @@ export function ThemePreviewUrlCard({ url }: { url: string }) {
{subtitleLine2}
>
) : null}
-
- {subtitleLine3}
>
);
const fullUrl = previewQuery.data?.fullUrl;
+ const loadFullCssText = async (): Promise => {
+ if (!fullUrl) throw new Error('Full theme URL is unavailable.');
+ const cached = await getCachedThemeCss(fullUrl);
+ if (cached) return cached;
+ const response = await fetch(fullUrl, { mode: 'cors' });
+ if (!response.ok) throw new Error(`Theme CSS fetch failed: ${response.status}`);
+ const text = await response.text();
+ await putCachedThemeCss(fullUrl, text);
+ return text;
+ };
return (
: undefined
+ showThirdPartyBanner ? (
+
+ ) : undefined
}
previewCssText={previewQuery.data?.previewText ?? ''}
+ loadFullCssText={fullUrl ? loadFullCssText : undefined}
scopeSlug={`chat-${basenameFromUrl(url)}`}
+ sourceLabel={sourceLabel}
copyText={url}
isFavorited={fullUrl ? isFav : false}
onToggleFavorite={fullUrl ? () => toggleFavorite() : undefined}
diff --git a/src/app/components/url-preview/TweakPreviewUrlCard.tsx b/src/app/components/url-preview/TweakPreviewUrlCard.tsx
index 43cd9a14a2..c20058341d 100644
--- a/src/app/components/url-preview/TweakPreviewUrlCard.tsx
+++ b/src/app/components/url-preview/TweakPreviewUrlCard.tsx
@@ -26,28 +26,14 @@ import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../.
import { isApprovedCatalogHostUrl, isThirdPartyThemeUrl } from '../../theme/themeApproval';
import { SableChatPreviewPlaceholder } from './SableChatPreviewPlaceholder';
import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner';
-
-function baseLabel(url: string): string {
- try {
- return new URL(url).hostname;
- } catch {
- return 'Unofficial tweak';
- }
-}
+import { CssViewerButton } from '../theme/CssViewerButton';
+import { pruneThemeTweakFavorites, themeUrlHostLabel } from '../../theme/themeLibrary';
function basenameFromFullSableUrl(url: string): string {
const tail = url.split('/').pop() ?? url;
return tail.replace(/\.sable\.css(\?.*)?$/i, '') || 'tweak';
}
-function pruneTweakFavorites(
- nextFavorites: ThemeRemoteTweakFavorite[],
- nextEnabledUrls: string[]
-): ThemeRemoteTweakFavorite[] {
- const enabled = new Set(nextEnabledUrls);
- return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl));
-}
-
function safeSlug(input: string): string {
return (input || 'tweak').replace(/[^a-zA-Z0-9_-]/g, '-') || 'tweak';
}
@@ -150,7 +136,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) {
const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== url);
const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url);
patchSettings({
- themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled),
themeRemoteEnabledTweakFullUrls: nextEnabled,
});
return;
@@ -164,7 +150,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) {
pinned: true,
};
patchSettings({
- themeRemoteTweakFavorites: pruneTweakFavorites(
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(
[...tweakFavorites, next],
enabledTweakFullUrls
),
@@ -191,7 +177,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) {
}
patchSettings({
themeRemoteEnabledTweakFullUrls: nextEnabled,
- themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled),
});
} else {
const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url);
@@ -210,7 +196,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) {
{
setUserTriggeredLoad(true);
@@ -253,7 +239,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) {
data.tags.length > 0 ? data.tags.join(', ') : '',
].filter(Boolean);
const descLine = descParts.join(' · ');
- const sourceLabel = isOfficial ? 'Official catalog' : baseLabel(url);
+ const sourceLabel = isOfficial ? 'Official catalog' : themeUrlHostLabel(url, 'Unofficial tweak');
return (
+
{showThirdPartyBanner ? (
-
+
) : undefined}
{styleBlock ? (
diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.css.ts b/src/app/features/settings/cosmetics/ThemeCatalogSettings.css.ts
new file mode 100644
index 0000000000..70e92daac2
--- /dev/null
+++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.css.ts
@@ -0,0 +1,15 @@
+import { style } from '@vanilla-extract/css';
+import { toRem } from 'folds';
+
+export const themeCardGrid = style({
+ display: 'grid',
+ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
+ gap: toRem(16),
+ width: '100%',
+ maxWidth: toRem(760),
+ '@media': {
+ [`screen and (max-width: ${toRem(700)})`]: {
+ gridTemplateColumns: 'minmax(0, 1fr)',
+ },
+ },
+});
diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
index 5694129f49..b410dc04e0 100644
--- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
+++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
@@ -47,6 +47,15 @@ import {
type SableThemeContrast,
} from '../../../theme/metadata';
import { previewUrlFromFullThemeUrl } from '../../../theme/previewUrls';
+import { updateInstalledCatalogPackages } from '../../../theme/catalogUpdater';
+import {
+ pruneThemeFavorites,
+ pruneThemeTweakFavorites,
+ themeSourceLabel,
+ themeUrlHostLabel,
+} from '../../../theme/themeLibrary';
+import { CssViewerButton } from '$components/theme/CssViewerButton';
+import * as css from './ThemeCatalogSettings.css';
export type CatalogPreviewRow = ThemePair & {
previewText: string;
@@ -103,6 +112,8 @@ type CatalogTweakCardProps = {
isOn: boolean;
onSetApplied: (v: boolean) => void;
onExport?: () => void;
+ cssText: string;
+ sourceLabel: string;
};
function CatalogTweakCard({
@@ -115,6 +126,8 @@ function CatalogTweakCard({
isOn,
onSetApplied,
onExport,
+ cssText,
+ sourceLabel,
}: CatalogTweakCardProps) {
const [copied, setCopied] = useTimeoutToggle();
const handleCopy = useCallback(async () => {
@@ -139,6 +152,9 @@ function CatalogTweakCard({
{description}
+
+ Source: {sourceLabel}
+
{thirdPartyChip && (
@@ -147,6 +163,11 @@ function CatalogTweakCard({
)}
+
{copyUrl && (
('themes');
+ const [catalogSection, setCatalogSection] = useState<'themes' | 'tweaks'>('themes');
const [kindFilter, setKindFilter] = useState<'all' | 'light' | 'dark'>('all');
const [contrastFilter, setContrastFilter] = useState<'all' | SableThemeContrast>('all');
const [tweakApplyFilter, setTweakApplyFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
-
+ const [catalogUpdateStatus, setCatalogUpdateStatus] = useState<
+ 'idle' | 'checking' | 'updated' | 'unchanged' | 'failed'
+ >('idle');
+ const [catalogUpdateMessage, setCatalogUpdateMessage] = useState(
+ 'Automatically checks installed catalog themes and tweaks for CSS changes.'
+ );
useEffect(() => setFavorites(getFavorites ? getFavorites : []), [getFavorites]);
const onThemeSearchChange: ChangeEventHandler = (e) =>
@@ -284,21 +312,58 @@ export function ThemeCatalogSettings({
[darkRemoteFullUrl, lightRemoteFullUrl, manualRemoteFullUrl]
);
- const pruneFavorites = useCallback(
- (nextFavorites: ThemeRemoteFavorite[], nextActiveUrls: string[]) => {
- const active = new Set(nextActiveUrls);
- return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl));
- },
- []
- );
-
- const pruneTweakFavorites = useCallback(
- (nextFavorites: ThemeRemoteTweakFavorite[], nextEnabledUrls: string[]) => {
- const enabled = new Set(nextEnabledUrls);
- return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl));
- },
- []
- );
+ const checkInstalledCatalogUpdates = useCallback(async () => {
+ if (catalogUpdateStatus === 'checking') return;
+ setCatalogUpdateStatus('checking');
+ setCatalogUpdateMessage('Checking installed catalog themes and tweaks…');
+ try {
+ const summary = await updateInstalledCatalogPackages({
+ catalogBase,
+ manifestUrl: catalogManifestUrl,
+ installedThemeUrls: Array.from(
+ new Set([...favorites.map((favorite) => favorite.fullUrl), ...activeUrls])
+ ),
+ installedTweakUrls: Array.from(
+ new Set([...tweakFavorites.map((favorite) => favorite.fullUrl), ...enabledTweakFullUrls])
+ ),
+ maxAgeMs: 0,
+ });
+ if (summary.updated > 0) {
+ setCatalogUpdateStatus('updated');
+ setCatalogUpdateMessage(
+ `Updated ${summary.updated} catalog ${summary.updated === 1 ? 'package' : 'packages'}.`
+ );
+ } else if (summary.failed > 0) {
+ setCatalogUpdateStatus('failed');
+ setCatalogUpdateMessage(
+ `Could not check ${summary.failed} ${summary.failed === 1 ? 'package' : 'packages'}. Cached CSS is still active.`
+ );
+ } else {
+ setCatalogUpdateStatus('unchanged');
+ setCatalogUpdateMessage(
+ summary.checked === 0
+ ? 'No installed themes or tweaks belong to the current catalog.'
+ : 'Installed catalog themes and tweaks are up to date.'
+ );
+ }
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['theme-local-previews'] }),
+ queryClient.invalidateQueries({ queryKey: ['theme-local-tweaks'] }),
+ ]);
+ } catch {
+ setCatalogUpdateStatus('failed');
+ setCatalogUpdateMessage('Could not check the catalog. Cached CSS is still active.');
+ }
+ }, [
+ activeUrls,
+ catalogBase,
+ catalogManifestUrl,
+ catalogUpdateStatus,
+ enabledTweakFullUrls,
+ favorites,
+ queryClient,
+ tweakFavorites,
+ ]);
const clearAssignmentsIfMatch = useCallback(
(fullUrl: string) => {
@@ -338,7 +403,15 @@ export function ThemeCatalogSettings({
const rows = await Promise.all(
pairs.map(async (pair) => {
const res = await fetch(pair.previewUrl, { mode: 'cors' });
- const previewText = res.ok ? await res.text() : '';
+ let previewText = res.ok ? await res.text() : '';
+ if (!previewText) {
+ try {
+ const fullResponse = await fetch(pair.fullUrl, { mode: 'cors' });
+ if (fullResponse.ok) previewText = await fullResponse.text();
+ } catch {
+ // Keep the catalog row visible even when neither CSS file is currently reachable.
+ }
+ }
const meta = parseSableThemeMetadata(previewText);
const fullFromMeta = extractFullThemeUrlFromPreview(previewText);
const fullInstallUrl =
@@ -449,28 +522,35 @@ export function ThemeCatalogSettings({
queryFn: async (): Promise => {
const rows = await Promise.all(
favorites.map(async (fav) => {
- const previewUrl = previewUrlFromFullThemeUrl(fav.fullUrl);
- if (!previewUrl) return undefined;
+ const previewUrl = previewUrlFromFullThemeUrl(fav.fullUrl) ?? fav.fullUrl;
try {
- let previewText: string;
- let fullCssText = '';
- if (isLocalImportThemeUrl(previewUrl)) {
- previewText = (await getCachedThemeCss(previewUrl)) ?? '';
- } else {
- const res = await fetch(previewUrl, { mode: 'cors' });
- if (!res.ok) return undefined;
- previewText = await res.text();
+ let fullCssText = (await getCachedThemeCss(fav.fullUrl)) ?? '';
+ if (!isLocalImportBundledUrl(fav.fullUrl)) {
+ try {
+ const fullRes = await fetch(fav.fullUrl, { mode: 'cors' });
+ if (fullRes.ok) fullCssText = await fullRes.text();
+ } catch {
+ // Cached full CSS keeps saved themes usable while offline.
+ }
}
- if (isLocalImportBundledUrl(fav.fullUrl)) {
- fullCssText = (await getCachedThemeCss(fav.fullUrl)) ?? '';
+
+ let previewText = '';
+ if (previewUrl === fav.fullUrl) {
+ previewText = fullCssText;
+ } else if (isLocalImportThemeUrl(previewUrl)) {
+ previewText = (await getCachedThemeCss(previewUrl)) ?? fullCssText;
} else {
- const fullRes = await fetch(fav.fullUrl, { mode: 'cors' });
- if (fullRes.ok) {
- fullCssText = await fullRes.text();
+ try {
+ const previewRes = await fetch(previewUrl, { mode: 'cors' });
+ previewText = previewRes.ok ? await previewRes.text() : fullCssText;
+ } catch {
+ previewText = fullCssText;
}
}
- const meta = parseSableThemeMetadata(previewText);
+ if (!previewText && !fullCssText) return undefined;
+ const metadataCss = previewText || fullCssText;
+ const meta = parseSableThemeMetadata(metadataCss);
const displayName = meta.name?.trim() || fav.displayName || fav.basename;
const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low';
const authorTrim = meta.author?.trim();
@@ -504,14 +584,16 @@ export function ThemeCatalogSettings({
const rows = await Promise.all(
tweakFavorites.map(async (fav) => {
try {
- let text: string;
- if (isLocalImportBundledUrl(fav.fullUrl)) {
- text = (await getCachedThemeCss(fav.fullUrl)) ?? '';
- } else {
- const res = await fetch(fav.fullUrl, { mode: 'cors' });
- if (!res.ok) return undefined;
- text = await res.text();
+ let text = (await getCachedThemeCss(fav.fullUrl)) ?? '';
+ if (!isLocalImportBundledUrl(fav.fullUrl)) {
+ try {
+ const res = await fetch(fav.fullUrl, { mode: 'cors' });
+ if (res.ok) text = await res.text();
+ } catch {
+ // Cached tweak CSS keeps the saved library available while offline.
+ }
}
+ if (!text) return undefined;
const meta = parseSableTweakMetadata(text);
const authorTrim = meta.author?.trim();
const row: LocalTweakRow = {
@@ -542,7 +624,7 @@ export function ThemeCatalogSettings({
.filter((u) => u !== fullUrl);
patchSettings({
...cleared,
- themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive),
});
},
[
@@ -552,7 +634,6 @@ export function ThemeCatalogSettings({
lightRemoteFullUrl,
manualRemoteFullUrl,
patchSettings,
- pruneFavorites,
]
);
@@ -640,6 +721,17 @@ export function ThemeCatalogSettings({
}
}, []);
+ const loadFullCssText = useCallback(async (url: string): Promise => {
+ const cached = await getCachedThemeCss(url);
+ if (cached) return cached;
+ if (isLocalImportBundledUrl(url)) throw new Error('Theme CSS is not cached.');
+ const response = await fetch(url, { mode: 'cors' });
+ if (!response.ok) throw new Error(`Theme CSS fetch failed: ${response.status}`);
+ const text = await response.text();
+ await putCachedThemeCss(url, text);
+ return text;
+ }, []);
+
const toggleFavorite = useCallback(
async (row: CatalogPreviewRow) => {
const existing = favorites.find((f: ThemeRemoteFavorite) => f.fullUrl === row.fullInstallUrl);
@@ -649,7 +741,7 @@ export function ThemeCatalogSettings({
const nextActive = activeUrls.filter((u) => u !== row.fullInstallUrl);
patchSettings({
...cleared,
- themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive),
});
return;
}
@@ -667,7 +759,7 @@ export function ThemeCatalogSettings({
themeRemoteFavorites: [...favorites, next],
});
},
- [activeUrls, clearAssignmentsIfMatch, favorites, patchSettings, prefetchFull, pruneFavorites]
+ [activeUrls, clearAssignmentsIfMatch, favorites, patchSettings, prefetchFull]
);
const installFromCatalogLight = useCallback(
@@ -699,10 +791,10 @@ export function ThemeCatalogSettings({
patchSettings({
themeRemoteLightFullUrl: row.fullInstallUrl,
themeRemoteLightKind: kind,
- themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive),
});
},
- [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites]
+ [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull]
);
const installFromCatalogDark = useCallback(
@@ -734,17 +826,10 @@ export function ThemeCatalogSettings({
patchSettings({
themeRemoteDarkFullUrl: row.fullInstallUrl,
themeRemoteDarkKind: kind,
- themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive),
});
},
- [
- favorites,
- lightRemoteFullUrl,
- manualRemoteFullUrl,
- patchSettings,
- prefetchFull,
- pruneFavorites,
- ]
+ [favorites, lightRemoteFullUrl, manualRemoteFullUrl, patchSettings, prefetchFull]
);
const installFromCatalogManual = useCallback(
@@ -776,10 +861,10 @@ export function ThemeCatalogSettings({
patchSettings({
themeRemoteManualFullUrl: row.fullInstallUrl,
themeRemoteManualKind: kind,
- themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive),
});
},
- [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites]
+ [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull]
);
const clearRemote = useCallback(() => {
@@ -830,7 +915,7 @@ export function ThemeCatalogSettings({
}
patchSettings({
themeRemoteEnabledTweakFullUrls: nextEnabled,
- themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled),
});
} else {
const nextEnabled = enabledTweakFullUrls.filter((u) => u !== trimmed);
@@ -855,7 +940,7 @@ export function ThemeCatalogSettings({
}
}
},
- [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites]
+ [enabledTweakFullUrls, patchSettings, prefetchFull, tweakFavorites]
);
const toggleCatalogTweakFavorite = useCallback(
@@ -865,7 +950,7 @@ export function ThemeCatalogSettings({
const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== row.fullUrl);
const nextEnabled = enabledTweakFullUrls.filter((u) => u !== row.fullUrl);
patchSettings({
- themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled),
themeRemoteEnabledTweakFullUrls: nextEnabled,
});
return;
@@ -879,13 +964,13 @@ export function ThemeCatalogSettings({
pinned: true,
};
patchSettings({
- themeRemoteTweakFavorites: pruneTweakFavorites(
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(
[...tweakFavorites, next],
enabledTweakFullUrls
),
});
},
- [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites]
+ [enabledTweakFullUrls, patchSettings, prefetchFull, tweakFavorites]
);
const removeTweakFavorite = useCallback(
@@ -893,11 +978,11 @@ export function ThemeCatalogSettings({
const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== fullUrl);
const nextEnabled = enabledTweakFullUrls.filter((u) => u !== fullUrl);
patchSettings({
- themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled),
themeRemoteEnabledTweakFullUrls: nextEnabled,
});
},
- [enabledTweakFullUrls, patchSettings, pruneTweakFavorites, tweakFavorites]
+ [enabledTweakFullUrls, patchSettings, tweakFavorites]
);
const downloadThemeFile = useCallback((row: LocalPreviewRow) => {
@@ -980,26 +1065,6 @@ export function ThemeCatalogSettings({
)}
-
-
-
- Clear
-
- }
- />
-
>
)}
@@ -1011,153 +1076,245 @@ export function ThemeCatalogSettings({
direction="Column"
gap="400"
>
- Saved themes
- {localPreviewsQuery.isPending && favorites.length > 0 && (
-
-
- Loading local previews…
+
+
+ Saved
+ setSavedSection('themes')}
+ >
+ Themes ({favorites.length})
+
+ setSavedSection('tweaks')}
+ >
+ Tweaks ({tweakFavorites.length})
+
- )}
+
+ {
+ checkInstalledCatalogUpdates().catch(() => undefined);
+ }}
+ >
+
+ {catalogUpdateStatus === 'checking' ? 'Checking…' : 'Check updates'}
+
+
+
+ Reset
+
+
+
- {favorites.length === 0 && (
-
- No saved themes yet. Star themes in the catalog to download them locally.
+ {catalogUpdateStatus !== 'idle' && catalogUpdateStatus !== 'checking' && (
+
+ {catalogUpdateMessage}
)}
- {localPreviewsQuery.isSuccess && favorites.length > 0 && (
- <>
- {localPreviewsQuery.data.length === 0 ? (
+ {savedSection === 'themes' && (
+
+ {localPreviewsQuery.isPending && favorites.length > 0 && (
+
+
+ Loading local previews…
+
+ )}
+
+ {favorites.length === 0 && (
- Could not load local previews. If this happens, the theme preview file may be
- missing or not paired as `*.preview.sable.css`.
+ No saved themes yet. Star themes in the catalog to download them locally.
- ) : (
-
- {localPreviewsQuery.data.map((row) => {
- const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme';
- const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light';
- const line1 = `${kindLabel} · ${row.contrast} contrast`;
- const line2 = `${row.author ? `by ${row.author}` : ''}${
- row.tags.length > 0
- ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}`
- : ''
- }`.trim();
- const subtitle = (
- <>
- {line1}
- {line2 ? (
+ )}
+
+ {localPreviewsQuery.isSuccess && favorites.length > 0 && (
+ <>
+ {localPreviewsQuery.data.length === 0 ? (
+
+ Could not load local previews. If this happens, the theme preview file may
+ be missing or not paired as `*.preview.sable.css`.
+
+ ) : (
+
+ {localPreviewsQuery.data.map((row) => {
+ const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme';
+ const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light';
+ const line1 = `${kindLabel} · ${row.contrast} contrast`;
+ const line2 = `${row.author ? `by ${row.author}` : ''}${
+ row.tags.length > 0
+ ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}`
+ : ''
+ }`.trim();
+ const subtitle = (
<>
-
- {line2}
+ {line1}
+ {line2 ? (
+ <>
+
+ {line2}
+ >
+ ) : null}
>
- ) : null}
- >
- );
- return (
- removeFavorite(row.fullUrl)}
- onExport={() => downloadThemeFile(row)}
- systemTheme={systemTheme}
- onApplyLight={systemTheme ? () => applyFavoriteToLight(row) : undefined}
- onApplyDark={systemTheme ? () => applyFavoriteToDark(row) : undefined}
- onApplyManual={
- !systemTheme ? () => applyFavoriteToManual(row) : undefined
- }
- isAppliedLight={lightRemoteFullUrl === row.fullUrl}
- isAppliedDark={darkRemoteFullUrl === row.fullUrl}
- isAppliedManual={manualRemoteFullUrl === row.fullUrl}
- />
- );
- })}
-
+ );
+ return (
+ removeFavorite(row.fullUrl)}
+ onExport={() => downloadThemeFile(row)}
+ systemTheme={systemTheme}
+ onApplyLight={
+ systemTheme ? () => applyFavoriteToLight(row) : undefined
+ }
+ onApplyDark={systemTheme ? () => applyFavoriteToDark(row) : undefined}
+ onApplyManual={
+ !systemTheme ? () => applyFavoriteToManual(row) : undefined
+ }
+ isAppliedLight={lightRemoteFullUrl === row.fullUrl}
+ isAppliedDark={darkRemoteFullUrl === row.fullUrl}
+ isAppliedManual={manualRemoteFullUrl === row.fullUrl}
+ />
+ );
+ })}
+
+ )}
+ >
)}
- >
- )}
-
-
-
- Saved tweaks
- {localTweaksQuery.isPending && tweakFavorites.length > 0 && (
-
-
- Loading tweaks…
)}
- {tweakFavorites.length === 0 && (
-
- No saved tweaks. Favorite tweaks in the catalog to keep them here, or enable a tweak
- to cache it automatically.
-
- )}
- {localTweaksQuery.isSuccess && tweakFavorites.length > 0 && (
-
- {localTweaksQuery.data.length === 0 ? (
+
+ {savedSection === 'tweaks' && (
+
+ {localTweaksQuery.isPending && tweakFavorites.length > 0 && (
+
+
+ Loading tweaks…
+
+ )}
+ {tweakFavorites.length === 0 && (
- Could not load tweak CSS. Check the URL or your connection.
+ No saved tweaks. Favorite tweaks in the catalog to keep them here, or enable a
+ tweak to cache it automatically.
- ) : (
- localTweaksQuery.data.map((row) => {
- const isOn = enabledTweakFullUrls.includes(row.fullUrl);
- const descParts = [
- row.description,
- row.author ? `by ${row.author}` : '',
- row.tags.length > 0 ? row.tags.join(', ') : '',
- ].filter(Boolean);
- const desc =
- descParts.join(' · ') ||
- 'Applies on top of your current theme after it loads.';
- return (
- removeTweakFavorite(row.fullUrl)}
- onExport={() => downloadTweakFile(row)}
- isOn={isOn}
- onSetApplied={(v) =>
- setTweakApplied(row.fullUrl, v, {
- displayName: row.displayName,
- basename: row.basename,
- })
- }
- />
- );
- })
+ )}
+ {localTweaksQuery.isSuccess && tweakFavorites.length > 0 && (
+
+ {localTweaksQuery.data.length === 0 ? (
+
+ Could not load tweak CSS. Check the URL or your connection.
+
+ ) : (
+ localTweaksQuery.data.map((row) => {
+ const isOn = enabledTweakFullUrls.includes(row.fullUrl);
+ const descParts = [
+ row.description,
+ row.author ? `by ${row.author}` : '',
+ row.tags.length > 0 ? row.tags.join(', ') : '',
+ ].filter(Boolean);
+ const desc =
+ descParts.join(' · ') ||
+ 'Applies on top of your current theme after it loads.';
+ return (
+ removeTweakFavorite(row.fullUrl)}
+ onExport={() => downloadTweakFile(row)}
+ cssText={row.fullCssText}
+ sourceLabel={themeSourceLabel({
+ importedLocal: row.importedLocal,
+ official:
+ !row.importedLocal &&
+ !isThirdPartyThemeUrl(
+ row.fullUrl,
+ clientConfig.themeCatalogApprovedHostPrefixes
+ ),
+ url: row.fullUrl,
+ })}
+ isOn={isOn}
+ onSetApplied={(v) =>
+ setTweakApplied(row.fullUrl, v, {
+ displayName: row.displayName,
+ basename: row.basename,
+ })
+ }
+ />
+ );
+ })
+ )}
+
)}
)}
@@ -1186,7 +1343,6 @@ export function ThemeCatalogSettings({
}
/>
-
{isAppearanceMode && browseOpen && (
-
-
- Browse catalog
-
- Download themes and tweaks from the catalog.
-
+
+
+
+ Browse catalog
+
+ Find, preview, and save catalog CSS.
+
+
+
+
+
+
-
-
-
+ Tweaks ({catalogTweakCount})
+
)}
@@ -1298,6 +1476,7 @@ export function ThemeCatalogSettings({
{catalogQuery.isSuccess &&
catalogHasEntries &&
catalogThemeCount > 0 &&
+ (!isAppearanceMode || catalogSection === 'themes') &&
previewsQuery.isSuccess && (
-
+ {!isAppearanceMode && }
-
+
{filteredRows.map((row) => {
const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme';
const kindLabel = row.kind === ThemeKind.Dark ? 'Dark' : 'Light';
@@ -1388,7 +1561,16 @@ export function ThemeCatalogSettings({
title={row.displayName}
subtitle={subtitle}
previewCssText={row.previewText}
+ loadFullCssText={() => loadFullCssText(row.fullInstallUrl)}
scopeSlug={`catalog-${slug}`}
+ sourceLabel={
+ isThirdPartyThemeUrl(
+ row.previewUrl,
+ clientConfig.themeCatalogApprovedHostPrefixes
+ )
+ ? themeUrlHostLabel(row.previewUrl)
+ : 'Official catalog'
+ }
copyText={row.previewUrl}
thirdParty={isThirdPartyThemeUrl(
row.previewUrl,
@@ -1427,6 +1609,7 @@ export function ThemeCatalogSettings({
{catalogQuery.isSuccess &&
catalogHasEntries &&
catalogTweakCount > 0 &&
+ (!isAppearanceMode || catalogSection === 'tweaks') &&
tweakDetailsQuery.isSuccess && (
-
+ {!isAppearanceMode && }
);
})}
@@ -1533,9 +1725,9 @@ export function ThemeCatalogSettings({
<>
}
@@ -1543,9 +1735,9 @@ export function ThemeCatalogSettings({
-
+
{
- const active = new Set(nextActiveUrls);
- return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl));
- },
- []
- );
-
- const pruneTweakFavorites = useCallback(
- (nextFavorites: ThemeRemoteTweakFavorite[], nextEnabledUrls: string[]) => {
- const enabled = new Set(nextEnabledUrls);
- return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl));
- },
- []
- );
-
useEffect(() => {
if (!open) {
setImportUrl('');
@@ -116,7 +101,10 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) {
? [...enabledTweakFullUrls]
: [...enabledTweakFullUrls, r.fullUrl];
patchSettings({
- themeRemoteTweakFavorites: pruneTweakFavorites([...tweakFavorites, next], nextEnabled),
+ themeRemoteTweakFavorites: pruneThemeTweakFavorites(
+ [...tweakFavorites, next],
+ nextEnabled
+ ),
themeRemoteEnabledTweakFullUrls: nextEnabled,
});
onClose();
@@ -142,20 +130,11 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) {
)
);
patchSettings({
- themeRemoteFavorites: pruneFavorites([...favorites, next], nextActive),
+ themeRemoteFavorites: pruneThemeFavorites([...favorites, next], nextActive),
});
onClose();
},
- [
- activeUrls,
- enabledTweakFullUrls,
- favorites,
- onClose,
- patchSettings,
- pruneFavorites,
- pruneTweakFavorites,
- tweakFavorites,
- ]
+ [activeUrls, enabledTweakFullUrls, favorites, onClose, patchSettings, tweakFavorites]
);
const onImportFileChange: ChangeEventHandler = (e) => {
diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts
index e39fc9b33b..6acc1b34cb 100644
--- a/src/app/features/settings/settingsLink.ts
+++ b/src/app/features/settings/settingsLink.ts
@@ -128,7 +128,6 @@ const settingsLinkFocusIdsBySection: Record void) | undefined;
type ThemeContextProviderProps = {
value: Theme;
@@ -59,6 +61,21 @@ vi.mock('$plugins/arborium', () => ({
kind === ThemeKind.Dark ? <>{children}> : <>{children}>,
}));
+vi.mock('../theme/cache', () => ({
+ getCachedThemeCss: vi.fn<(url: string) => Promise>(async () =>
+ cachedCss ? cachedCss : undefined
+ ),
+ putCachedThemeCss: vi.fn<(url: string, cssText: string) => Promise>(async () => undefined),
+ subscribeThemeCacheUpdates: vi.fn<
+ (listener: (update: { url: string; contentHash: string }) => void) => () => void
+ >((listener: (update: { url: string; contentHash: string }) => void) => {
+ cacheUpdateListener = listener;
+ return () => {
+ cacheUpdateListener = undefined;
+ };
+ }),
+}));
+
beforeEach(() => {
systemThemeKind = ThemeKind.Light;
activeTheme = {
@@ -70,6 +87,8 @@ beforeEach(() => {
settings.underlineLinks = false;
settings.reducedMotion = false;
settings.themeRemoteEnabledTweakFullUrls = [];
+ cachedCss = '';
+ cacheUpdateListener = undefined;
document.body.className = '';
document.body.style.filter = '';
});
@@ -105,4 +124,30 @@ describe('ThemeManager', () => {
expect(document.body).toHaveClass('test-dark-theme');
expect(document.body).not.toHaveClass('test-light-theme');
});
+
+ it('reloads active CSS when the cached content changes without a URL change', async () => {
+ const themeUrl = 'https://catalog.example/theme.sable.css';
+ cachedCss = 'body { --sable-primary-main: red; }';
+ activeTheme = {
+ id: 'test-remote',
+ kind: ThemeKind.Dark,
+ classNames: ['test-dark-theme'],
+ remoteFullUrl: themeUrl,
+ };
+
+ render(
+
+ child
+
+ );
+
+ await waitFor(() =>
+ expect(document.getElementById('sable-remote-theme-style')).toHaveTextContent('red')
+ );
+ cachedCss = 'body { --sable-primary-main: blue; }';
+ cacheUpdateListener?.({ url: themeUrl, contentHash: 'new-hash' });
+ await waitFor(() =>
+ expect(document.getElementById('sable-remote-theme-style')).toHaveTextContent('blue')
+ );
+ });
});
diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx
index 9dae919490..69e34282f2 100644
--- a/src/app/pages/ThemeManager.tsx
+++ b/src/app/pages/ThemeManager.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
-import { useEffect } from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
import { configClass, varsClass } from 'folds';
import {
DarkTheme,
@@ -12,8 +12,14 @@ import {
import { ArboriumThemeBridge } from '$plugins/arborium';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
-import { getCachedThemeCss, putCachedThemeCss } from '../theme/cache';
+import { useOptionalClientConfig } from '$hooks/useClientConfig';
+import { getCachedThemeCss, putCachedThemeCss, subscribeThemeCacheUpdates } from '../theme/cache';
import { isLocalImportBundledUrl } from '../theme/localImportUrls';
+import { themeCatalogListingBaseUrl } from '../theme/catalogDefaults';
+import {
+ THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS,
+ updateInstalledCatalogPackages,
+} from '../theme/catalogUpdater';
const REMOTE_STYLE_ID = 'sable-remote-theme-style';
const REMOTE_TWEAKS_STYLE_ID = 'sable-remote-tweaks-style';
@@ -39,6 +45,74 @@ async function loadRemoteThemeCssText(url: string): Promise
return text;
}
+function CatalogThemeAutoUpdater() {
+ const clientConfig = useOptionalClientConfig();
+ const [catalogEnabled] = useSetting(settingsAtom, 'themeRemoteCatalogEnabled');
+ const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites');
+ const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites');
+ const [manualUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl');
+ const [lightUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl');
+ const [darkUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl');
+ const [enabledTweakUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls');
+ const inFlightRef = useRef | null>(null);
+
+ const installedThemeUrls = useMemo(
+ () =>
+ Array.from(
+ new Set(
+ [
+ ...(favorites ?? []).map((favorite) => favorite.fullUrl),
+ manualUrl,
+ lightUrl,
+ darkUrl,
+ ].filter((url): url is string => Boolean(url))
+ )
+ ),
+ [darkUrl, favorites, lightUrl, manualUrl]
+ );
+ const installedTweakUrls = useMemo(
+ () =>
+ Array.from(
+ new Set([
+ ...(tweakFavorites ?? []).map((favorite) => favorite.fullUrl),
+ ...(enabledTweakUrls ?? []),
+ ])
+ ),
+ [enabledTweakUrls, tweakFavorites]
+ );
+
+ const checkForUpdates = useCallback(() => {
+ if (!catalogEnabled || !clientConfig || inFlightRef.current) return;
+ if (installedThemeUrls.length === 0 && installedTweakUrls.length === 0) return;
+ const task = updateInstalledCatalogPackages({
+ catalogBase: themeCatalogListingBaseUrl(clientConfig.themeCatalogBaseUrl?.trim()),
+ manifestUrl: clientConfig.themeCatalogManifestUrl?.trim() || undefined,
+ installedThemeUrls,
+ installedTweakUrls,
+ maxAgeMs: THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS,
+ })
+ .catch(() => undefined)
+ .finally(() => {
+ if (inFlightRef.current === task) inFlightRef.current = null;
+ });
+ inFlightRef.current = task;
+ }, [catalogEnabled, clientConfig, installedThemeUrls, installedTweakUrls]);
+
+ useEffect(() => {
+ checkForUpdates();
+ }, [checkForUpdates]);
+
+ useEffect(() => {
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible') checkForUpdates();
+ };
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
+ }, [checkForUpdates]);
+
+ return null;
+}
+
export function UnAuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind();
@@ -94,7 +168,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
let cancelled = false;
if (url) {
- (async () => {
+ const applyThemeCss = async () => {
const text = await loadRemoteThemeCssText(url);
if (cancelled) return;
if (!text) {
@@ -108,7 +182,15 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
document.head.appendChild(node);
}
node.textContent = text;
- })();
+ };
+ applyThemeCss().catch(() => undefined);
+ const unsubscribe = subscribeThemeCacheUpdates((update) => {
+ if (update.url === url) applyThemeCss().catch(() => undefined);
+ });
+ return () => {
+ cancelled = true;
+ unsubscribe();
+ };
} else {
document.getElementById(REMOTE_STYLE_ID)?.remove();
}
@@ -127,7 +209,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
return undefined;
}
- (async () => {
+ const applyTweakCss = async () => {
const texts = await Promise.all(urls.map((url) => loadRemoteThemeCssText(url.trim())));
if (cancelled) return;
const chunks = texts.filter((text): text is string => Boolean(text));
@@ -138,16 +220,25 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
document.head.appendChild(node);
}
node.textContent = chunks.join('\n\n');
- })();
+ };
+ applyTweakCss().catch(() => undefined);
+ const activeUrls = new Set(urls.map((url) => url.trim()));
+ const unsubscribe = subscribeThemeCacheUpdates((update) => {
+ if (activeUrls.has(update.url)) applyTweakCss().catch(() => undefined);
+ });
return () => {
cancelled = true;
+ unsubscribe();
};
}, [enabledTweakUrls]);
return (
-
- {children}
-
+ <>
+
+
+ {children}
+
+ >
);
}
diff --git a/src/app/theme/cache.ts b/src/app/theme/cache.ts
index b3659b1685..b10b68c1e4 100644
--- a/src/app/theme/cache.ts
+++ b/src/app/theme/cache.ts
@@ -1,40 +1,115 @@
const DB_NAME = 'sable-theme-cache';
const DB_VERSION = 1;
const STORE = 'themes';
+const UPDATE_CHANNEL = 'sable-theme-cache-updates';
export type CachedThemeEntry = {
url: string;
cssText: string;
cachedAt: number;
+ checkedAt?: number;
+ contentHash?: string;
+ etag?: string;
+ lastModified?: string;
};
+export type ThemeCacheRevalidationResult =
+ | { status: 'updated'; url: string; previousHash?: string; contentHash: string }
+ | { status: 'unchanged'; url: string; contentHash: string }
+ | { status: 'skipped'; url: string; contentHash?: string }
+ | { status: 'failed'; url: string; error: string };
+
+export type ThemeCacheUpdate = {
+ url: string;
+ contentHash: string;
+};
+
+export type ThemeCacheStats = {
+ entries: number;
+ localEntries: number;
+ remoteEntries: number;
+ totalBytes: number;
+ localBytes: number;
+ remoteBytes: number;
+ lastCheckedAt?: number;
+};
+
+const updateListeners = new Set<(update: ThemeCacheUpdate) => void>();
+let updateChannel: BroadcastChannel | undefined;
+
+function isThemeCacheUpdate(value: unknown): value is ThemeCacheUpdate {
+ if (!value || typeof value !== 'object') return false;
+ const update = value as Record;
+ return typeof update.url === 'string' && typeof update.contentHash === 'string';
+}
+
+function getUpdateChannel(): BroadcastChannel | undefined {
+ if (typeof BroadcastChannel === 'undefined') return undefined;
+ if (!updateChannel) {
+ updateChannel = new BroadcastChannel(UPDATE_CHANNEL);
+ updateChannel.addEventListener('message', (event: MessageEvent) => {
+ const update = event.data;
+ if (!isThemeCacheUpdate(update)) return;
+ updateListeners.forEach((listener) => listener(update));
+ });
+ }
+ return updateChannel;
+}
+
+function notifyThemeCacheUpdated(update: ThemeCacheUpdate): void {
+ updateListeners.forEach((listener) => listener(update));
+ getUpdateChannel()?.postMessage(update);
+}
+
+export function subscribeThemeCacheUpdates(
+ listener: (update: ThemeCacheUpdate) => void
+): () => void {
+ updateListeners.add(listener);
+ getUpdateChannel();
+ return () => updateListeners.delete(listener);
+}
+
function openDb(): Promise {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.addEventListener('error', () => reject(req.error));
req.addEventListener('success', () => resolve(req.result));
req.addEventListener('upgradeneeded', () => {
- req.result.createObjectStore(STORE, { keyPath: 'url' });
+ if (!req.result.objectStoreNames.contains(STORE)) {
+ req.result.createObjectStore(STORE, { keyPath: 'url' });
+ }
});
});
}
-export async function getCachedThemeCss(url: string): Promise {
+export async function hashThemeCss(cssText: string): Promise {
+ try {
+ const bytes = new TextEncoder().encode(cssText);
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(
+ ''
+ );
+ } catch {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < cssText.length; i += 1) {
+ hash = Math.imul(hash ^ cssText.charCodeAt(i), 0x01000193);
+ }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+}
+
+export async function getCachedThemeEntry(url: string): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(url);
req.addEventListener('error', () => reject(req.error));
- req.addEventListener('success', () => {
- const row = req.result as CachedThemeEntry | undefined;
- resolve(row?.cssText);
- });
+ req.addEventListener('success', () => resolve(req.result as CachedThemeEntry | undefined));
});
}
-export async function putCachedThemeCss(url: string, cssText: string): Promise {
+async function writeCachedThemeEntry(entry: CachedThemeEntry): Promise {
const db = await openDb();
- const entry: CachedThemeEntry = { url, cssText, cachedAt: Date.now() };
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(entry);
@@ -43,6 +118,83 @@ export async function putCachedThemeCss(url: string, cssText: string): Promise {
+ return (await getCachedThemeEntry(url))?.cssText;
+}
+
+export async function putCachedThemeCss(url: string, cssText: string): Promise {
+ const [existing, contentHash] = await Promise.all([
+ getCachedThemeEntry(url),
+ hashThemeCss(cssText),
+ ]);
+ const now = Date.now();
+ await writeCachedThemeEntry({
+ ...existing,
+ url,
+ cssText,
+ cachedAt: now,
+ checkedAt: now,
+ contentHash,
+ etag: undefined,
+ lastModified: undefined,
+ });
+ if (existing && existing.contentHash !== contentHash) {
+ notifyThemeCacheUpdated({ url, contentHash });
+ }
+}
+
+export async function revalidateCachedThemeCss(
+ url: string,
+ maxAgeMs = 0
+): Promise {
+ try {
+ const existing = await getCachedThemeEntry(url);
+ const lastCheck = existing?.checkedAt ?? existing?.cachedAt ?? 0;
+ if (maxAgeMs > 0 && Date.now() - lastCheck < maxAgeMs) {
+ return { status: 'skipped', url, contentHash: existing?.contentHash };
+ }
+
+ const response = await fetch(url, { mode: 'cors', cache: 'no-cache' });
+ if (!response.ok) {
+ return { status: 'failed', url, error: `Theme update check failed (${response.status}).` };
+ }
+ const cssText = await response.text();
+ const contentHash = await hashThemeCss(cssText);
+ const previousHash =
+ existing?.contentHash ?? (existing ? await hashThemeCss(existing.cssText) : undefined);
+ const now = Date.now();
+
+ if (existing && previousHash === contentHash) {
+ await writeCachedThemeEntry({
+ ...existing,
+ checkedAt: now,
+ contentHash,
+ etag: response.headers.get('etag') ?? existing.etag,
+ lastModified: response.headers.get('last-modified') ?? existing.lastModified,
+ });
+ return { status: 'unchanged', url, contentHash };
+ }
+
+ await writeCachedThemeEntry({
+ url,
+ cssText,
+ cachedAt: now,
+ checkedAt: now,
+ contentHash,
+ etag: response.headers.get('etag') ?? undefined,
+ lastModified: response.headers.get('last-modified') ?? undefined,
+ });
+ notifyThemeCacheUpdated({ url, contentHash });
+ return { status: 'updated', url, previousHash, contentHash };
+ } catch (error) {
+ return {
+ status: 'failed',
+ url,
+ error: error instanceof Error ? error.message : 'Theme update check failed.',
+ };
+ }
+}
+
export async function clearThemeCache(): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -52,3 +204,60 @@ export async function clearThemeCache(): Promise {
tx.addEventListener('error', () => reject(tx.error));
});
}
+
+export async function getThemeCacheStats(): Promise {
+ const db = await openDb();
+ const entries = await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readonly');
+ const req = tx.objectStore(STORE).getAll();
+ req.addEventListener('error', () => reject(req.error));
+ req.addEventListener('success', () => resolve(req.result as CachedThemeEntry[]));
+ });
+ const stats: ThemeCacheStats = {
+ entries: entries.length,
+ localEntries: 0,
+ remoteEntries: 0,
+ totalBytes: 0,
+ localBytes: 0,
+ remoteBytes: 0,
+ };
+ entries.forEach((entry) => {
+ const bytes = new TextEncoder().encode(entry.cssText).byteLength;
+ const local = entry.url.startsWith('sable-import://');
+ stats.totalBytes += bytes;
+ if (local) {
+ stats.localEntries += 1;
+ stats.localBytes += bytes;
+ } else {
+ stats.remoteEntries += 1;
+ stats.remoteBytes += bytes;
+ const checkedAt = entry.checkedAt ?? entry.cachedAt;
+ if (!stats.lastCheckedAt || checkedAt > stats.lastCheckedAt) stats.lastCheckedAt = checkedAt;
+ }
+ });
+ return stats;
+}
+
+/** Clears only refetchable network CSS. Uploaded local theme packages are preserved. */
+export async function clearRemoteThemeCache(): Promise {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ const store = tx.objectStore(STORE);
+ const req = store.openCursor();
+ let removed = 0;
+ req.addEventListener('error', () => reject(req.error));
+ req.addEventListener('success', () => {
+ const cursor = req.result;
+ if (!cursor) return;
+ const entry = cursor.value as CachedThemeEntry;
+ if (!entry.url.startsWith('sable-import://')) {
+ cursor.delete();
+ removed += 1;
+ }
+ cursor.continue();
+ });
+ tx.addEventListener('complete', () => resolve(removed));
+ tx.addEventListener('error', () => reject(tx.error));
+ });
+}
diff --git a/src/app/theme/catalogUpdater.ts b/src/app/theme/catalogUpdater.ts
new file mode 100644
index 0000000000..bae5c7f002
--- /dev/null
+++ b/src/app/theme/catalogUpdater.ts
@@ -0,0 +1,57 @@
+import { revalidateCachedThemeCss, type ThemeCacheRevalidationResult } from './cache';
+import { fetchThemeCatalogBundle } from './catalog';
+
+export const THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS = 6 * 60 * 60_000;
+const lastCatalogChecks = new Map();
+
+export type CatalogUpdateSummary = {
+ checked: number;
+ updated: number;
+ unchanged: number;
+ skipped: number;
+ failed: number;
+ results: ThemeCacheRevalidationResult[];
+};
+
+export type UpdateInstalledCatalogPackagesOptions = {
+ catalogBase: string;
+ manifestUrl?: string;
+ installedThemeUrls: string[];
+ installedTweakUrls: string[];
+ maxAgeMs?: number;
+};
+
+export async function updateInstalledCatalogPackages({
+ catalogBase,
+ manifestUrl,
+ installedThemeUrls,
+ installedTweakUrls,
+ maxAgeMs = THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS,
+}: UpdateInstalledCatalogPackagesOptions): Promise {
+ const catalogCheckKey = `${catalogBase}\n${manifestUrl ?? ''}`;
+ const previousCatalogCheck = lastCatalogChecks.get(catalogCheckKey) ?? 0;
+ if (maxAgeMs > 0 && Date.now() - previousCatalogCheck < maxAgeMs) {
+ return { checked: 0, updated: 0, unchanged: 0, skipped: 0, failed: 0, results: [] };
+ }
+ const bundle = await fetchThemeCatalogBundle(catalogBase, { manifestUrl });
+ const catalogThemeUrls = new Set(bundle.themes.map((theme) => theme.fullUrl));
+ lastCatalogChecks.set(catalogCheckKey, Date.now());
+ const catalogTweakUrls = new Set(bundle.tweaks.map((tweak) => tweak.fullUrl));
+
+ const installed = new Set([
+ ...installedThemeUrls.filter((url) => catalogThemeUrls.has(url)),
+ ...installedTweakUrls.filter((url) => catalogTweakUrls.has(url)),
+ ]);
+ const results = await Promise.all(
+ Array.from(installed, (url) => revalidateCachedThemeCss(url, maxAgeMs))
+ );
+
+ return {
+ checked: results.filter((result) => result.status !== 'skipped').length,
+ updated: results.filter((result) => result.status === 'updated').length,
+ unchanged: results.filter((result) => result.status === 'unchanged').length,
+ skipped: results.filter((result) => result.status === 'skipped').length,
+ failed: results.filter((result) => result.status === 'failed').length,
+ results,
+ };
+}
diff --git a/src/app/theme/processThemeImport.ts b/src/app/theme/processThemeImport.ts
index eabcf3ad77..effe96770e 100644
--- a/src/app/theme/processThemeImport.ts
+++ b/src/app/theme/processThemeImport.ts
@@ -37,6 +37,62 @@ export type ProcessedThemeImport =
}
| { ok: false; error: string };
+export type ProcessedUploadedSableCss =
+ | {
+ ok: true;
+ role: 'theme';
+ fullUrl?: string;
+ previewCssForCard: string;
+ fullCssForCard?: string;
+ displayName: string;
+ basename: string;
+ kind: 'light' | 'dark';
+ importedLocal: boolean;
+ previewOnly: boolean;
+ }
+ | {
+ ok: true;
+ role: 'tweak';
+ fullUrl: string;
+ cssText: string;
+ displayName: string;
+ basename: string;
+ description?: string;
+ author?: string;
+ tags: string[];
+ importedLocal: true;
+ }
+ | { ok: false; error: string };
+
+export function isSableCssAttachmentFileName(fileName: string): boolean {
+ return /\.sable\.css$/i.test(fileName);
+}
+
+function fallbackStableId(sourceKey: string): string {
+ let first = 0x811c9dc5;
+ let second = 0x9e3779b9;
+ for (let i = 0; i < sourceKey.length; i += 1) {
+ const code = sourceKey.charCodeAt(i);
+ first = Math.imul(first ^ code, 0x01000193);
+ second = Math.imul(second ^ code, 0x85ebca6b);
+ }
+ return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0)
+ .toString(16)
+ .padStart(8, '0')}`;
+}
+
+async function stableImportId(sourceKey: string): Promise {
+ try {
+ const bytes = new TextEncoder().encode(sourceKey);
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+ .slice(0, 32);
+ } catch {
+ return fallbackStableId(sourceKey);
+ }
+}
+
function basenameFromHttpsUrl(url: string): string {
try {
const u = new URL(url);
@@ -274,3 +330,130 @@ export async function processPastedOrUploadedCss(
importedLocal: true,
};
}
+
+/** Process a Matrix file attachment without treating a preview-only file as a full theme. */
+export async function processUploadedSableCssAttachment(
+ cssText: string,
+ fileName: string,
+ sourceKey: string
+): Promise {
+ const trimmed = cssText.trim();
+ if (!trimmed) return { ok: false, error: 'The uploaded CSS file is empty.' };
+
+ const packageKind = getSableCssPackageKind(trimmed);
+ if (packageKind === 'unknown') {
+ return {
+ ok: false,
+ error: 'This file does not contain @sable-theme or @sable-tweak metadata.',
+ };
+ }
+
+ const stableId = await stableImportId(sourceKey);
+
+ if (packageKind === 'tweak') {
+ const meta = parseSableTweakMetadata(trimmed);
+ const basename = tweakBasename(meta, fileName);
+ const fullUrl = localImportTweakFullUrl(stableId);
+ await putCachedThemeCss(fullUrl, trimmed);
+ return {
+ ok: true,
+ role: 'tweak',
+ fullUrl,
+ cssText: trimmed,
+ displayName: meta.name?.trim() || basename,
+ basename,
+ description: meta.description?.trim() || undefined,
+ author: meta.author?.trim() || undefined,
+ tags: meta.tags ?? [],
+ importedLocal: true,
+ };
+ }
+
+ const meta = parseSableThemeMetadata(trimmed);
+ const fileBasename = fileName
+ .replace(/\.preview\.sable\.css$/i, '')
+ .replace(/\.sable\.css$/i, '');
+ const displayName = meta.name?.trim() || fileBasename || 'Uploaded theme';
+ const basename =
+ meta.id?.trim() ||
+ displayName.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '') ||
+ 'uploaded';
+ const isPreviewFile = /\.preview\.sable\.css$/i.test(fileName);
+
+ if (isPreviewFile) {
+ const fullUrl = extractFullThemeUrlFromPreview(trimmed);
+ if (!fullUrl) {
+ return {
+ ok: true,
+ role: 'theme',
+ previewCssForCard: trimmed,
+ displayName,
+ basename,
+ kind: metaKindToLd(meta),
+ importedLocal: false,
+ previewOnly: true,
+ };
+ }
+
+ try {
+ const response = await fetch(fullUrl, { mode: 'cors' });
+ if (!response.ok) {
+ return {
+ ok: true,
+ role: 'theme',
+ previewCssForCard: trimmed,
+ displayName,
+ basename,
+ kind: metaKindToLd(meta),
+ importedLocal: false,
+ previewOnly: true,
+ };
+ }
+ const fullCss = await response.text();
+ if (getSableCssPackageKind(fullCss) === 'tweak') {
+ return { ok: false, error: 'The preview file points to a tweak instead of a full theme.' };
+ }
+ await putCachedThemeCss(fullUrl, fullCss);
+ return {
+ ok: true,
+ role: 'theme',
+ fullUrl,
+ previewCssForCard: trimmed,
+ fullCssForCard: fullCss,
+ displayName,
+ basename,
+ kind: metaKindToLd(meta),
+ importedLocal: false,
+ previewOnly: false,
+ };
+ } catch {
+ return {
+ ok: true,
+ role: 'theme',
+ previewCssForCard: trimmed,
+ displayName,
+ basename,
+ kind: metaKindToLd(meta),
+ importedLocal: false,
+ previewOnly: true,
+ };
+ }
+ }
+
+ const fullUrl = localImportFullUrl(stableId);
+ const previewUrl = localImportPreviewUrl(stableId);
+ await putCachedThemeCss(fullUrl, trimmed);
+ await putCachedThemeCss(previewUrl, trimmed);
+ return {
+ ok: true,
+ role: 'theme',
+ fullUrl,
+ previewCssForCard: trimmed,
+ fullCssForCard: trimmed,
+ displayName,
+ basename,
+ kind: metaKindToLd(meta),
+ importedLocal: true,
+ previewOnly: false,
+ };
+}
diff --git a/src/app/theme/themeLibrary.test.ts b/src/app/theme/themeLibrary.test.ts
new file mode 100644
index 0000000000..d61161c3f0
--- /dev/null
+++ b/src/app/theme/themeLibrary.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ pruneThemeFavorites,
+ pruneThemeTweakFavorites,
+ themeSourceLabel,
+ themeUrlHostLabel,
+} from './themeLibrary';
+
+describe('themeLibrary', () => {
+ it('keeps pinned and active theme records', () => {
+ const favorites = [
+ { fullUrl: 'active', displayName: 'Active', basename: 'active', kind: 'dark' as const },
+ {
+ fullUrl: 'pinned',
+ displayName: 'Pinned',
+ basename: 'pinned',
+ kind: 'light' as const,
+ pinned: true,
+ },
+ { fullUrl: 'unused', displayName: 'Unused', basename: 'unused', kind: 'dark' as const },
+ ];
+
+ expect(pruneThemeFavorites(favorites, ['active']).map((item) => item.fullUrl)).toEqual([
+ 'active',
+ 'pinned',
+ ]);
+ });
+
+ it('keeps pinned and enabled tweak records', () => {
+ const favorites = [
+ { fullUrl: 'enabled', displayName: 'Enabled', basename: 'enabled' },
+ { fullUrl: 'pinned', displayName: 'Pinned', basename: 'pinned', pinned: true },
+ { fullUrl: 'unused', displayName: 'Unused', basename: 'unused' },
+ ];
+
+ expect(pruneThemeTweakFavorites(favorites, ['enabled']).map((item) => item.fullUrl)).toEqual([
+ 'enabled',
+ 'pinned',
+ ]);
+ });
+
+ it('provides consistent source labels', () => {
+ expect(themeSourceLabel({ importedLocal: true })).toBe('Uploaded file');
+ expect(themeSourceLabel({ official: true })).toBe('Official catalog');
+ expect(themeSourceLabel({ url: 'https://themes.example/night.sable.css' })).toBe(
+ 'themes.example'
+ );
+ expect(themeUrlHostLabel('not a URL', 'Unknown')).toBe('Unknown');
+ });
+});
diff --git a/src/app/theme/themeLibrary.ts b/src/app/theme/themeLibrary.ts
new file mode 100644
index 0000000000..7c752ea778
--- /dev/null
+++ b/src/app/theme/themeLibrary.ts
@@ -0,0 +1,40 @@
+import type { ThemeRemoteFavorite, ThemeRemoteTweakFavorite } from '$state/settings';
+
+export function pruneThemeFavorites(
+ favorites: ThemeRemoteFavorite[],
+ activeUrls: Array
+): ThemeRemoteFavorite[] {
+ const active = new Set(activeUrls.filter((url): url is string => Boolean(url)));
+ return favorites.filter((favorite) => favorite.pinned === true || active.has(favorite.fullUrl));
+}
+
+export function pruneThemeTweakFavorites(
+ favorites: ThemeRemoteTweakFavorite[],
+ enabledUrls: string[]
+): ThemeRemoteTweakFavorite[] {
+ const enabled = new Set(enabledUrls);
+ return favorites.filter((favorite) => favorite.pinned === true || enabled.has(favorite.fullUrl));
+}
+
+export function themeUrlHostLabel(url: string, fallback = 'Third-party source'): string {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return fallback;
+ }
+}
+
+export function themeSourceLabel({
+ importedLocal,
+ official,
+ url,
+}: {
+ importedLocal?: boolean;
+ official?: boolean;
+ url?: string;
+}): string {
+ if (importedLocal) return 'Uploaded file';
+ if (official) return 'Official catalog';
+ if (url) return themeUrlHostLabel(url);
+ return 'Local theme';
+}