diff --git a/config.json b/config.json
index f0c3c8b61..02ed78536 100644
--- a/config.json
+++ b/config.json
@@ -15,6 +15,9 @@
"settingsLinkBaseUrl": "https://app.sable.moe",
+ "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/",
+ "themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"],
+
"slidingSync": {
"enabled": true
},
diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
index cc07d5082..c38e67504 100644
--- a/src/app/components/RenderMessageContent.tsx
+++ b/src/app/components/RenderMessageContent.tsx
@@ -29,7 +29,15 @@ import {
UnsupportedContent,
VideoContent,
} from './message';
-import { UrlPreviewCard, UrlPreviewHolder, ClientPreview, youtubeUrl } from './url-preview';
+import {
+ UrlPreviewCard,
+ UrlPreviewHolder,
+ ClientPreview,
+ ThemePreviewUrlCard,
+ TweakPreviewUrlCard,
+ youtubeUrl,
+} from './url-preview';
+import { isHttpsFullSableCssUrl } from '../theme/previewUrls';
import { Image, MediaControl, PersistedVolumeVideo } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
@@ -82,6 +90,7 @@ function RenderMessageContentInternal({
const [autoplayGifs] = useSetting(settingsAtom, 'autoplayGifs');
const [captionPosition] = useSetting(settingsAtom, 'captionPosition');
+ const [themeChatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl');
const settingsLinkBaseUrl = useSettingsLinkBaseUrl();
const captionPositionMap = {
[CaptionPosition.Above]: 'column-reverse',
@@ -110,6 +119,15 @@ function RenderMessageContentInternal({
);
if (filteredUrls.length === 0) return undefined;
+ const themePreviewUrls = themeChatAny
+ ? filteredUrls.filter((u) => /\.preview\.sable\.css(\?|#|$)/i.test(u))
+ : [];
+ const themeToRender = themePreviewUrls.filter((u) => /^https:\/\//i.test(u));
+
+ const tweakCandidateUrls = themeChatAny
+ ? filteredUrls.filter((u) => isHttpsFullSableCssUrl(u))
+ : [];
+
const analyzed = filteredUrls.map((url) => ({
url,
type: getMediaType(url),
@@ -120,7 +138,15 @@ function RenderMessageContentInternal({
return (
+ {themeToRender.map((url) => (
+
+ ))}
+ {tweakCandidateUrls.map((url) => (
+
+ ))}
{toRender.map(({ url, type }) => {
+ if (themeToRender.includes(url)) return null;
+ if (tweakCandidateUrls.includes(url)) return null;
if (type) {
return ;
}
@@ -135,9 +161,9 @@ function RenderMessageContentInternal({
);
},
- [ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview]
+ [ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview, themeChatAny]
);
- const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined;
+ const messageUrlsPreview = urlPreview || themeChatAny ? renderUrlsPreview : undefined;
const renderCaption = () => {
const hasCaption = content.body && content.body.trim().length > 0;
diff --git a/src/app/components/theme/ThemeMigrationBanner.tsx b/src/app/components/theme/ThemeMigrationBanner.tsx
new file mode 100644
index 000000000..793e84755
--- /dev/null
+++ b/src/app/components/theme/ThemeMigrationBanner.tsx
@@ -0,0 +1,163 @@
+import { useCallback, useMemo, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ config,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+} from 'folds';
+import { useStore } from 'jotai/react';
+
+import { useOptionalClientConfig } from '$hooks/useClientConfig';
+import { useSetting } from '$state/hooks/settings';
+import { trimTrailingSlash } from '$utils/common';
+import { defaultSettings, settingsAtom } from '$state/settings';
+import { stopPropagation } from '$utils/keyboard';
+
+import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch';
+import { DEFAULT_THEME_CATALOG_BASE } from '../../theme/catalogDefaults';
+import { needsLegacyThemeMigration } from '../../theme/legacyToCatalogMap';
+import { runLegacyThemeMigration } from '../../theme/migrateLegacyThemes';
+
+export function ThemeMigrationBanner() {
+ const store = useStore();
+ const [themeMigrationDismissed] = useSetting(settingsAtom, 'themeMigrationDismissed');
+ const [themeId] = useSetting(settingsAtom, 'themeId');
+ const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
+ const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
+ const patchSettings = usePatchSettings();
+ const clientConfig = useOptionalClientConfig();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const visible = useMemo(
+ () =>
+ needsLegacyThemeMigration({
+ ...defaultSettings,
+ themeMigrationDismissed: themeMigrationDismissed ?? false,
+ themeId,
+ lightThemeId,
+ darkThemeId,
+ }),
+ [themeMigrationDismissed, themeId, lightThemeId, darkThemeId]
+ );
+
+ const catalogBase = trimTrailingSlash(
+ clientConfig?.themeCatalogBaseUrl?.trim() || DEFAULT_THEME_CATALOG_BASE
+ );
+
+ const dismiss = useCallback(() => {
+ patchSettings({ themeMigrationDismissed: true });
+ }, [patchSettings]);
+
+ const dismissSafe = useCallback(() => {
+ if (busy) return;
+ dismiss();
+ }, [busy, dismiss]);
+
+ const migrate = useCallback(async () => {
+ setError(null);
+ setBusy(true);
+ try {
+ const current = store.get(settingsAtom);
+ const result = await runLegacyThemeMigration(current, catalogBase);
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ patchSettings(result.partial);
+ } finally {
+ setBusy(false);
+ }
+ }, [catalogBase, patchSettings, store]);
+
+ if (!visible) return null;
+
+ return (
+ }>
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/components/theme/ThemePreviewCard.tsx b/src/app/components/theme/ThemePreviewCard.tsx
new file mode 100644
index 000000000..7002b9292
--- /dev/null
+++ b/src/app/components/theme/ThemePreviewCard.tsx
@@ -0,0 +1,283 @@
+import { type ReactNode, useCallback, useMemo } from 'react';
+import {
+ Box,
+ Button,
+ Icon,
+ IconButton,
+ Icons,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ toRem,
+ config,
+} from 'folds';
+
+import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
+import { copyToClipboard } from '$utils/dom';
+import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../../theme/previewCss';
+
+export type ThemePreviewCardProps = {
+ title: string;
+ subtitle?: ReactNode;
+ beforePreview?: ReactNode;
+ previewCssText: string;
+ scopeSlug: string;
+ copyText?: string;
+ isFavorited?: boolean;
+ onToggleFavorite?: () => void | Promise;
+
+ systemTheme: boolean;
+ onApplyLight?: () => void | Promise;
+ onApplyDark?: () => void | Promise;
+ onApplyManual?: () => void | Promise;
+ isAppliedLight?: boolean;
+ isAppliedDark?: boolean;
+ isAppliedManual?: boolean;
+
+ onRevert?: () => void;
+ canRevert?: boolean;
+ thirdParty?: boolean;
+};
+
+function safeSlug(input: string): string {
+ return (input || 'theme').replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme';
+}
+
+export function ThemePreviewCard({
+ title,
+ subtitle,
+ beforePreview,
+ previewCssText,
+ scopeSlug,
+ copyText,
+ isFavorited,
+ onToggleFavorite,
+ systemTheme,
+ onApplyLight,
+ onApplyDark,
+ onApplyManual,
+ isAppliedLight,
+ isAppliedDark,
+ isAppliedManual,
+ onRevert,
+ canRevert,
+ thirdParty,
+}: ThemePreviewCardProps) {
+ const [copied, setCopied] = useTimeoutToggle();
+
+ const scopeClass = useMemo(() => `sable-theme-preview--${safeSlug(scopeSlug)}`, [scopeSlug]);
+
+ const styleBlock = useMemo(() => {
+ const vars = extractSafePreviewCustomProperties(previewCssText);
+ return buildPreviewStyleBlock(vars, scopeClass);
+ }, [previewCssText, scopeClass]);
+
+ const handleCopy = useCallback(async () => {
+ if (!copyText) return;
+ if (await copyToClipboard(copyText)) setCopied();
+ }, [copyText, setCopied]);
+
+ return (
+
+
+
+
+ {title}
+ {thirdParty && (
+
+ Third-party theme. Only use themes you trust.
+
+ }
+ >
+ {(triggerRef) => (
+
+ )}
+
+ )}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+
+ {copyText && (
+ {
+ handleCopy().catch(() => undefined);
+ }}
+ >
+
+
+ )}
+
+ {typeof isFavorited === 'boolean' && onToggleFavorite && (
+ {
+ Promise.resolve(onToggleFavorite()).catch(() => undefined);
+ }}
+ >
+
+
+ )}
+
+
+
+ {beforePreview}
+
+ {styleBlock ? (
+ <>
+
+
+
+ Sample text
+
+
+
+ Primary
+
+
+ Surface
+
+
+
+ >
+ ) : (
+
+ No preview tokens
+
+ )}
+
+
+ {systemTheme ? (
+ <>
+ {onApplyLight && (
+
+ )}
+ {onApplyDark && (
+
+ )}
+ >
+ ) : (
+ onApplyManual && (
+
+ )
+ )}
+
+ {onRevert && (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/components/url-preview/ThemePreviewUrlCard.tsx b/src/app/components/url-preview/ThemePreviewUrlCard.tsx
new file mode 100644
index 000000000..3f707deca
--- /dev/null
+++ b/src/app/components/url-preview/ThemePreviewUrlCard.tsx
@@ -0,0 +1,448 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Box, Text } from 'folds';
+import { useStore } from 'jotai/react';
+
+import { useClientConfig } from '$hooks/useClientConfig';
+import { ThemeKind } from '$hooks/useTheme';
+import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom, type Settings, type ThemeRemoteFavorite } from '$state/settings';
+import {
+ extractFullThemeUrlFromPreview,
+ parseSableThemeMetadata,
+ type SableThemeContrast,
+} from '../../theme/metadata';
+import { putCachedThemeCss } from '../../theme/cache';
+import { fullUrlFromPreviewUrl } from '../../theme/previewUrls';
+import { ThemePreviewCard } from '../theme/ThemePreviewCard';
+import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner';
+
+function isHttps(url: string): boolean {
+ return /^https:\/\//i.test(url);
+}
+
+function isPreviewThemeUrl(url: string): boolean {
+ return /\.preview\.sable\.css(\?|#|$)/i.test(url);
+}
+
+function isApprovedByPrefix(url: string, prefixes: string[] | undefined): boolean {
+ if (!prefixes || prefixes.length === 0) return true;
+ return prefixes.some((p) => url.startsWith(p));
+}
+
+function basenameFromUrl(url: string): string {
+ const tail = url.split('/').pop() ?? url;
+ return tail.replace(/\.preview\.sable\.css(\?|#|$)/i, '').replace(/\.sable\.css(\?|#|$)/i, '');
+}
+
+function baseLabel(url: string): string {
+ try {
+ const u = new URL(url);
+ return u.hostname;
+ } catch {
+ return 'Unofficial theme';
+ }
+}
+
+export function ThemePreviewUrlCard({ url }: { url: string }) {
+ const clientConfig = useClientConfig();
+ const store = useStore();
+ const patchSettings = usePatchSettings();
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const [chatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl');
+ const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites');
+ const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
+ const [manualRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl');
+ const [lightRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl');
+ const [darkRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl');
+
+ const allowed = useMemo(() => {
+ if (!chatAny) return false;
+ return isHttps(url) && isPreviewThemeUrl(url);
+ }, [chatAny, url]);
+
+ const isOfficial = useMemo(
+ () => isApprovedByPrefix(url, clientConfig.themeCatalogApprovedHostPrefixes),
+ [clientConfig.themeCatalogApprovedHostPrefixes, url]
+ );
+
+ const showThirdPartyBanner = useMemo(() => {
+ const p = clientConfig.themeCatalogApprovedHostPrefixes;
+ return Boolean(p && p.length > 0 && !isApprovedByPrefix(url, p));
+ }, [clientConfig.themeCatalogApprovedHostPrefixes, url]);
+
+ const previewQuery = useQuery({
+ queryKey: ['theme-preview-embed', url],
+ enabled: allowed,
+ staleTime: 10 * 60_000,
+ queryFn: async () => {
+ const res = await fetch(url, { mode: 'cors' });
+ if (!res.ok) throw new Error(`theme preview fetch failed: ${res.status}`);
+ const previewText = await res.text();
+ const meta = parseSableThemeMetadata(previewText);
+ const fullFromMeta = extractFullThemeUrlFromPreview(previewText);
+ const fullUrl = fullUrlFromPreviewUrl(url, fullFromMeta);
+ const kind = meta.kind ?? ThemeKind.Light;
+ const displayName = meta.name?.trim() || basenameFromUrl(url);
+ const author = meta.author?.trim() || undefined;
+ const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low';
+ const tags = meta.tags ?? [];
+ return { previewText, fullUrl, kind, displayName, author, contrast, tags };
+ },
+ });
+
+ const revertRef = useRef | null>(null);
+ const lastAutoSavedUrlRef = useRef(null);
+ const [canRevert, setCanRevert] = useState(false);
+ const [favoriteTouched, setFavoriteTouched] = useState(false);
+
+ const appliedHere = useMemo(() => {
+ const u = previewQuery.data?.fullUrl;
+ if (!u) return false;
+ if (systemTheme) {
+ return lightRemoteFullUrl === u || darkRemoteFullUrl === u;
+ }
+ return manualRemoteFullUrl === u;
+ }, [
+ previewQuery.data?.fullUrl,
+ systemTheme,
+ lightRemoteFullUrl,
+ darkRemoteFullUrl,
+ manualRemoteFullUrl,
+ ]);
+
+ const canRevertUi = useMemo(() => canRevert || appliedHere, [canRevert, appliedHere]);
+
+ const isFav = useMemo(
+ () => favorites.some((f) => f.fullUrl === previewQuery.data?.fullUrl),
+ [favorites, previewQuery.data?.fullUrl]
+ );
+
+ const pruneFavorites = useCallback(
+ (nextFavorites: ThemeRemoteFavorite[], nextActive: string[]) => {
+ 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;
+
+ const existing = favorites.find((f) => f.fullUrl === fullUrl);
+ if (existing) {
+ setFavoriteTouched(false);
+ const cleared: Partial = {};
+ if (lightRemoteFullUrl === fullUrl) {
+ cleared.themeRemoteLightFullUrl = undefined;
+ cleared.themeRemoteLightKind = undefined;
+ }
+ if (darkRemoteFullUrl === fullUrl) {
+ cleared.themeRemoteDarkFullUrl = undefined;
+ cleared.themeRemoteDarkKind = undefined;
+ }
+ if (manualRemoteFullUrl === fullUrl) {
+ cleared.themeRemoteManualFullUrl = undefined;
+ cleared.themeRemoteManualKind = undefined;
+ }
+
+ const nextFavs = favorites.filter((f) => f.fullUrl !== fullUrl);
+ 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) });
+ return;
+ }
+
+ setFavoriteTouched(true);
+ const res = await fetch(fullUrl, { mode: 'cors' });
+ if (!mountedRef.current) return;
+ if (!res.ok) return;
+ const cssText = await res.text();
+ if (!mountedRef.current) return;
+ await putCachedThemeCss(fullUrl, cssText);
+ if (!mountedRef.current) return;
+
+ const next: ThemeRemoteFavorite = {
+ fullUrl,
+ displayName: previewQuery.data?.displayName ?? basenameFromUrl(fullUrl),
+ basename: basenameFromUrl(fullUrl),
+ kind: previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light',
+ pinned: true,
+ };
+
+ patchSettings({ themeRemoteFavorites: [...favorites, next] });
+ }, [
+ darkRemoteFullUrl,
+ favorites,
+ lightRemoteFullUrl,
+ manualRemoteFullUrl,
+ patchSettings,
+ previewQuery.data,
+ pruneFavorites,
+ ]);
+
+ const applyManual = useCallback(() => {
+ const fullUrl = previewQuery.data?.fullUrl;
+ if (!fullUrl) return;
+ const kind = previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light';
+ patchSettings({ themeRemoteManualFullUrl: fullUrl, themeRemoteManualKind: kind });
+ }, [patchSettings, previewQuery.data]);
+
+ const applyLightSlot = useCallback(() => {
+ const fullUrl = previewQuery.data?.fullUrl;
+ if (!fullUrl) return;
+ const kind = previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light';
+ patchSettings({ themeRemoteLightFullUrl: fullUrl, themeRemoteLightKind: kind });
+ }, [patchSettings, previewQuery.data]);
+
+ const applyDarkSlot = useCallback(() => {
+ const fullUrl = previewQuery.data?.fullUrl;
+ if (!fullUrl) return;
+ const kind = previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light';
+ patchSettings({ themeRemoteDarkFullUrl: fullUrl, themeRemoteDarkKind: kind });
+ }, [patchSettings, previewQuery.data]);
+
+ const ensureFavorited = useCallback(async (): Promise => {
+ const fullUrl = previewQuery.data?.fullUrl;
+ if (!fullUrl) return false;
+ if (favorites.some((f) => f.fullUrl === fullUrl)) return false;
+
+ const res = await fetch(fullUrl, { mode: 'cors' });
+ if (!mountedRef.current) return false;
+ if (!res.ok) return false;
+ const cssText = await res.text();
+ if (!mountedRef.current) return false;
+ await putCachedThemeCss(fullUrl, cssText);
+ if (!mountedRef.current) return false;
+
+ const next: ThemeRemoteFavorite = {
+ fullUrl,
+ displayName: previewQuery.data!.displayName,
+ basename: basenameFromUrl(fullUrl),
+ kind: previewQuery.data!.kind === ThemeKind.Dark ? 'dark' : 'light',
+ pinned: false,
+ };
+
+ patchSettings({ themeRemoteFavorites: [...favorites, next] });
+ return true;
+ }, [favorites, patchSettings, previewQuery.data]);
+
+ const snapshotAnd = useCallback(
+ async (fn: () => void, nextApplied: Partial) => {
+ const current = store.get(settingsAtom);
+ if (
+ nextApplied.themeRemoteManualFullUrl &&
+ current.themeRemoteManualFullUrl === nextApplied.themeRemoteManualFullUrl
+ ) {
+ return;
+ }
+ if (
+ nextApplied.themeRemoteLightFullUrl &&
+ current.themeRemoteLightFullUrl === nextApplied.themeRemoteLightFullUrl
+ ) {
+ return;
+ }
+ if (
+ nextApplied.themeRemoteDarkFullUrl &&
+ current.themeRemoteDarkFullUrl === nextApplied.themeRemoteDarkFullUrl
+ ) {
+ return;
+ }
+ revertRef.current = {
+ themeId: current.themeId,
+ themeRemoteManualFullUrl: current.themeRemoteManualFullUrl,
+ themeRemoteManualKind: current.themeRemoteManualKind,
+ themeRemoteLightFullUrl: current.themeRemoteLightFullUrl,
+ themeRemoteLightKind: current.themeRemoteLightKind,
+ themeRemoteDarkFullUrl: current.themeRemoteDarkFullUrl,
+ themeRemoteDarkKind: current.themeRemoteDarkKind,
+ } satisfies Partial;
+ setCanRevert(true);
+
+ if (!favoriteTouched) {
+ const added = await ensureFavorited();
+ if (!mountedRef.current) return;
+ lastAutoSavedUrlRef.current = added ? (previewQuery.data?.fullUrl ?? null) : null;
+ } else {
+ lastAutoSavedUrlRef.current = null;
+ }
+
+ fn();
+ if (!mountedRef.current) return;
+
+ const nextActive = [
+ nextApplied.themeRemoteManualFullUrl ?? manualRemoteFullUrl,
+ nextApplied.themeRemoteLightFullUrl ?? lightRemoteFullUrl,
+ nextApplied.themeRemoteDarkFullUrl ?? darkRemoteFullUrl,
+ ].filter((u): u is string => Boolean(u && u.trim().length > 0));
+
+ patchSettings({
+ themeRemoteFavorites: pruneFavorites(
+ store.get(settingsAtom).themeRemoteFavorites,
+ nextActive
+ ),
+ });
+ },
+ [
+ ensureFavorited,
+ favoriteTouched,
+ lightRemoteFullUrl,
+ darkRemoteFullUrl,
+ manualRemoteFullUrl,
+ patchSettings,
+ previewQuery.data?.fullUrl,
+ pruneFavorites,
+ store,
+ ]
+ );
+
+ const revert = useCallback(() => {
+ const syncFavoritesAfterRevert = (removeAutoSavedFullUrl: string | null) => {
+ const after = store.get(settingsAtom);
+ let favs = after.themeRemoteFavorites;
+ if (removeAutoSavedFullUrl) {
+ favs = favs.filter((f) => f.fullUrl !== removeAutoSavedFullUrl);
+ }
+ const nextActive = [
+ after.themeRemoteManualFullUrl,
+ after.themeRemoteLightFullUrl,
+ after.themeRemoteDarkFullUrl,
+ ].filter((u): u is string => Boolean(u && u.trim().length > 0));
+ patchSettings({
+ themeRemoteFavorites: pruneFavorites(favs, nextActive),
+ });
+ };
+
+ const snap = revertRef.current;
+ if (snap) {
+ patchSettings(snap);
+ syncFavoritesAfterRevert(lastAutoSavedUrlRef.current);
+
+ revertRef.current = null;
+ lastAutoSavedUrlRef.current = null;
+ setCanRevert(false);
+ return;
+ }
+
+ const u = previewQuery.data?.fullUrl;
+ if (!u) return;
+ const partial: Partial = {};
+ if (lightRemoteFullUrl === u) {
+ partial.themeRemoteLightFullUrl = undefined;
+ partial.themeRemoteLightKind = undefined;
+ }
+ if (darkRemoteFullUrl === u) {
+ partial.themeRemoteDarkFullUrl = undefined;
+ partial.themeRemoteDarkKind = undefined;
+ }
+ if (manualRemoteFullUrl === u) {
+ partial.themeRemoteManualFullUrl = undefined;
+ partial.themeRemoteManualKind = undefined;
+ partial.themeId = previewQuery.data?.kind === ThemeKind.Dark ? 'dark-theme' : 'light-theme';
+ }
+ if (Object.keys(partial).length === 0) return;
+ patchSettings(partial);
+ syncFavoritesAfterRevert(null);
+ }, [
+ darkRemoteFullUrl,
+ lightRemoteFullUrl,
+ manualRemoteFullUrl,
+ patchSettings,
+ previewQuery.data?.fullUrl,
+ previewQuery.data?.kind,
+ pruneFavorites,
+ store,
+ ]);
+
+ if (!allowed) return null;
+
+ const title = previewQuery.data?.displayName ?? 'Theme preview';
+ let kindLabel = 'Theme';
+ if (previewQuery.data?.kind === ThemeKind.Dark) kindLabel = 'Dark';
+ else if (previewQuery.data?.kind === ThemeKind.Light) kindLabel = 'Light';
+ const contrastLabel = previewQuery.data?.contrast ? `${previewQuery.data.contrast} contrast` : '';
+ const authorLabel = previewQuery.data?.author ? `by ${previewQuery.data.author}` : '';
+ const tagsLabel =
+ previewQuery.data?.tags && previewQuery.data.tags.length > 0
+ ? previewQuery.data.tags.join(', ')
+ : '';
+ const sourceLabel = isOfficial ? 'Official theme' : baseLabel(url);
+ const subtitleLine1 = [kindLabel, contrastLabel].filter(Boolean).join(' · ');
+ const subtitleLine2 = [authorLabel, tagsLabel].filter(Boolean).join(' · ');
+ const subtitleLine3 = sourceLabel;
+ const subtitle = (
+ <>
+ {subtitleLine1}
+ {subtitleLine2 ? (
+ <>
+
+ {subtitleLine2}
+ >
+ ) : null}
+
+ {subtitleLine3}
+ >
+ );
+ const fullUrl = previewQuery.data?.fullUrl;
+
+ return (
+
+ : undefined
+ }
+ previewCssText={previewQuery.data?.previewText ?? ''}
+ scopeSlug={`chat-${basenameFromUrl(url)}`}
+ copyText={url}
+ isFavorited={fullUrl ? isFav : false}
+ onToggleFavorite={fullUrl ? () => toggleFavorite() : undefined}
+ systemTheme={systemTheme}
+ onApplyLight={
+ systemTheme && fullUrl
+ ? () => snapshotAnd(() => applyLightSlot(), { themeRemoteLightFullUrl: fullUrl })
+ : undefined
+ }
+ onApplyDark={
+ systemTheme && fullUrl
+ ? () => snapshotAnd(() => applyDarkSlot(), { themeRemoteDarkFullUrl: fullUrl })
+ : undefined
+ }
+ onApplyManual={
+ !systemTheme && fullUrl
+ ? () => snapshotAnd(() => applyManual(), { themeRemoteManualFullUrl: fullUrl })
+ : undefined
+ }
+ isAppliedLight={fullUrl ? lightRemoteFullUrl === fullUrl : false}
+ isAppliedDark={fullUrl ? darkRemoteFullUrl === fullUrl : false}
+ isAppliedManual={fullUrl ? manualRemoteFullUrl === fullUrl : false}
+ canRevert={canRevertUi}
+ onRevert={revert}
+ />
+ {previewQuery.isPending && (
+
+ Loading preview…
+
+ )}
+
+ );
+}
diff --git a/src/app/components/url-preview/ThemeThirdPartyBanner.tsx b/src/app/components/url-preview/ThemeThirdPartyBanner.tsx
new file mode 100644
index 000000000..6fedcc548
--- /dev/null
+++ b/src/app/components/url-preview/ThemeThirdPartyBanner.tsx
@@ -0,0 +1,34 @@
+import { Box, Icon, Icons, Text, toRem, config } from 'folds';
+
+type ThemeThirdPartyBannerProps = {
+ hostLabel: string;
+};
+
+export function ThemeThirdPartyBanner({ hostLabel }: ThemeThirdPartyBannerProps) {
+ return (
+
+
+
+
+
+ Third-party theme
+
+
+ This preview is hosted on {hostLabel}, outside the Sable catalog allowlist. Saving or
+ applying installs full theme CSS from that host—only use themes you trust.
+
+
+
+
+ );
+}
diff --git a/src/app/components/url-preview/TweakPreviewUrlCard.tsx b/src/app/components/url-preview/TweakPreviewUrlCard.tsx
new file mode 100644
index 000000000..9362214a2
--- /dev/null
+++ b/src/app/components/url-preview/TweakPreviewUrlCard.tsx
@@ -0,0 +1,385 @@
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+ Box,
+ Icon,
+ IconButton,
+ Icons,
+ Spinner,
+ Switch,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ config,
+ toRem,
+} from 'folds';
+
+import { useClientConfig } from '$hooks/useClientConfig';
+import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
+import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom, type ThemeRemoteTweakFavorite } from '$state/settings';
+import { copyToClipboard } from '$utils/dom';
+import { putCachedThemeCss } from '../../theme/cache';
+import { getSableCssPackageKind, parseSableTweakMetadata } from '../../theme/metadata';
+import { isHttpsFullSableCssUrl } from '../../theme/previewUrls';
+import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../../theme/previewCss';
+import { isThirdPartyThemeUrl } from '../../theme/themeApproval';
+import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner';
+
+function baseLabel(url: string): string {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return 'Unofficial tweak';
+ }
+}
+
+function isApprovedByPrefix(url: string, prefixes: string[] | undefined): boolean {
+ if (!prefixes || prefixes.length === 0) return true;
+ return prefixes.some((p) => url.startsWith(p));
+}
+
+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';
+}
+
+type TweakPreviewData = {
+ cssText: string;
+ displayName: string;
+ description?: string;
+ author?: string;
+ tags: string[];
+ basename: string;
+};
+
+export function TweakPreviewUrlCard({ url }: { url: string }) {
+ const clientConfig = useClientConfig();
+ const patchSettings = usePatchSettings();
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const [chatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl');
+ const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites');
+ const [enabledTweakFullUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls');
+
+ const [copied, setCopied] = useTimeoutToggle();
+
+ const allowed = useMemo(() => chatAny && isHttpsFullSableCssUrl(url), [chatAny, url]);
+
+ const isOfficial = useMemo(
+ () => isApprovedByPrefix(url, clientConfig.themeCatalogApprovedHostPrefixes),
+ [clientConfig.themeCatalogApprovedHostPrefixes, url]
+ );
+
+ const showThirdPartyBanner = useMemo(
+ () =>
+ Boolean(
+ clientConfig.themeCatalogApprovedHostPrefixes &&
+ clientConfig.themeCatalogApprovedHostPrefixes.length > 0 &&
+ !isApprovedByPrefix(url, clientConfig.themeCatalogApprovedHostPrefixes)
+ ),
+ [clientConfig.themeCatalogApprovedHostPrefixes, url]
+ );
+
+ const tweakPreviewQuery = useQuery({
+ queryKey: ['tweak-preview-embed', url],
+ enabled: allowed,
+ staleTime: 10 * 60_000,
+ queryFn: async (): Promise => {
+ const res = await fetch(url, { mode: 'cors' });
+ if (!res.ok) throw new Error(`tweak fetch failed: ${res.status}`);
+ const cssText = await res.text();
+ if (getSableCssPackageKind(cssText) !== 'tweak') return null;
+ const meta = parseSableTweakMetadata(cssText);
+ const basename = meta.id?.trim() || basenameFromFullSableUrl(url);
+ return {
+ cssText,
+ displayName: meta.name?.trim() || basenameFromFullSableUrl(url),
+ description: meta.description?.trim() || undefined,
+ author: meta.author?.trim() || undefined,
+ tags: meta.tags ?? [],
+ basename,
+ };
+ },
+ });
+
+ const { data } = tweakPreviewQuery;
+
+ const scopeClass = useMemo(() => `sable-tweak-preview--${safeSlug(url)}`, [url]);
+
+ const styleBlock = useMemo(() => {
+ if (!data?.cssText) return '';
+ const vars = extractSafePreviewCustomProperties(data.cssText);
+ return buildPreviewStyleBlock(vars, scopeClass);
+ }, [data?.cssText, scopeClass]);
+
+ const handleCopy = useCallback(async () => {
+ if (await copyToClipboard(url)) setCopied();
+ }, [setCopied, url]);
+
+ const toggleFavorite = useCallback(async () => {
+ if (!data) return;
+ const existing = tweakFavorites.find((f) => f.fullUrl === url);
+ if (existing) {
+ const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== url);
+ const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url);
+ patchSettings({
+ themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ });
+ return;
+ }
+ await putCachedThemeCss(url, data.cssText);
+ if (!mountedRef.current) return;
+ const next: ThemeRemoteTweakFavorite = {
+ fullUrl: url,
+ displayName: data.displayName,
+ basename: data.basename,
+ pinned: true,
+ };
+ patchSettings({
+ themeRemoteTweakFavorites: pruneTweakFavorites(
+ [...tweakFavorites, next],
+ enabledTweakFullUrls
+ ),
+ });
+ }, [data, enabledTweakFullUrls, patchSettings, tweakFavorites, url]);
+
+ const setTweakEnabled = useCallback(
+ async (apply: boolean) => {
+ if (!data) return;
+ if (apply) {
+ await putCachedThemeCss(url, data.cssText);
+ if (!mountedRef.current) return;
+ const nextEnabled = enabledTweakFullUrls.includes(url)
+ ? [...enabledTweakFullUrls]
+ : [...enabledTweakFullUrls, url];
+ const nextFavs = [...tweakFavorites];
+ if (!nextFavs.some((f) => f.fullUrl === url)) {
+ nextFavs.push({
+ fullUrl: url,
+ displayName: data.displayName,
+ basename: data.basename,
+ pinned: false,
+ });
+ }
+ patchSettings({
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ });
+ } else {
+ const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url);
+ patchSettings({
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ themeRemoteTweakFavorites: pruneTweakFavorites(tweakFavorites, nextEnabled),
+ });
+ }
+ },
+ [data, enabledTweakFullUrls, patchSettings, tweakFavorites, url]
+ );
+
+ if (!allowed) return null;
+
+ if (tweakPreviewQuery.isPending) {
+ return (
+
+
+
+ Loading tweak…
+
+
+ );
+ }
+
+ if (tweakPreviewQuery.isError || data === null || data === undefined) {
+ return null;
+ }
+
+ const isFav = tweakFavorites.some((f) => f.fullUrl === url);
+ const isOn = enabledTweakFullUrls.includes(url);
+ const thirdPartyIcon = isThirdPartyThemeUrl(url, clientConfig.themeCatalogApprovedHostPrefixes);
+
+ const descParts = [
+ data.description,
+ data.author ? `by ${data.author}` : '',
+ data.tags.length > 0 ? data.tags.join(', ') : '',
+ ].filter(Boolean);
+ const descLine = descParts.join(' · ');
+ const sourceLabel = isOfficial ? 'Official catalog' : baseLabel(url);
+
+ return (
+
+
+
+
+ {data.displayName}
+ {thirdPartyIcon && (
+
+ Third-party tweak. Only enable CSS you trust.
+
+ }
+ >
+ {(triggerRef) => (
+
+ )}
+
+ )}
+
+ {descLine ? (
+
+ {descLine}
+
+ ) : null}
+
+ {sourceLabel}
+
+
+
+
+ {
+ handleCopy().catch(() => undefined);
+ }}
+ >
+
+
+ {
+ toggleFavorite().catch(() => undefined);
+ }}
+ >
+
+
+ {
+ setTweakEnabled(v).catch(() => undefined);
+ }}
+ />
+
+
+
+ {showThirdPartyBanner ? : undefined}
+
+ {styleBlock ? (
+ <>
+
+
+
+ Sample text
+
+
+
+ Primary
+
+
+ Surface
+
+
+
+ >
+ ) : (
+
+ No preview tokens in this tweak
+
+ )}
+
+ );
+}
diff --git a/src/app/components/url-preview/index.ts b/src/app/components/url-preview/index.ts
index ccf60be87..fce263b0c 100644
--- a/src/app/components/url-preview/index.ts
+++ b/src/app/components/url-preview/index.ts
@@ -1,3 +1,6 @@
export * from './UrlPreview';
export * from './UrlPreviewCard';
export * from './ClientPreview';
+export * from './ThemePreviewUrlCard';
+export * from './TweakPreviewUrlCard';
+export * from './ThemeThirdPartyBanner';
diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx
index 3d50d886e..fcbbbb926 100644
--- a/src/app/features/settings/cosmetics/Cosmetics.tsx
+++ b/src/app/features/settings/cosmetics/Cosmetics.tsx
@@ -1,4 +1,4 @@
-import { MouseEventHandler, useState } from 'react';
+import { MouseEventHandler, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
@@ -258,17 +258,45 @@ type CosmeticsProps = {
};
export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) {
+ const [themeBrowserOpen, setThemeBrowserOpen] = useState(false);
+ const appearanceScrollRef = useRef(null);
+
+ useEffect(() => {
+ let timeoutId: number | undefined;
+ const el = appearanceScrollRef.current;
+
+ if (themeBrowserOpen && el) {
+ const scrollToTop = () => {
+ el.scrollTop = 0;
+ };
+
+ scrollToTop();
+ requestAnimationFrame(scrollToTop);
+ timeoutId = window.setTimeout(scrollToTop, 0);
+ }
+
+ return () => {
+ if (timeoutId !== undefined) {
+ window.clearTimeout(timeoutId);
+ }
+ };
+ }, [themeBrowserOpen]);
+
return (
-
+
-
-
-
-
-
+
+ {!themeBrowserOpen && (
+ <>
+
+
+
+
+ >
+ )}
diff --git a/src/app/features/settings/cosmetics/ThemeAppearanceSection.tsx b/src/app/features/settings/cosmetics/ThemeAppearanceSection.tsx
new file mode 100644
index 000000000..d3a4e2f74
--- /dev/null
+++ b/src/app/features/settings/cosmetics/ThemeAppearanceSection.tsx
@@ -0,0 +1,186 @@
+import { useCallback, useEffect } from 'react';
+import { Box, Button, Switch, Text } from 'folds';
+
+import { SettingMenuSelector } from '$components/setting-menu-selector';
+import { SequenceCard } from '$components/sequence-card';
+import { SettingTile } from '$components/setting-tile';
+import { DarkTheme, LightTheme, Theme, ThemeKind, useThemeNames, useThemes } from '$hooks/useTheme';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { SequenceCardStyle } from '$features/settings/styles.css';
+import { useStore } from 'jotai/react';
+
+import { useThemeCatalogOnboardingGate } from './ThemeCatalogOnboarding';
+import { ThemeCatalogSettings, usePatchSettings } from './ThemeCatalogSettings';
+
+function makeThemeOptions(themes: Theme[], themeNames: Record) {
+ return themes.map((theme) => ({
+ value: theme.id,
+ label: themeNames[theme.id] ?? theme.id,
+ }));
+}
+
+function SelectTheme({ disabled }: Readonly<{ disabled?: boolean }>) {
+ const themes = useThemes();
+ const themeNames = useThemeNames();
+ const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
+
+ const themeOptions = makeThemeOptions(themes, themeNames);
+ const selectedThemeId =
+ themeOptions.find((theme) => theme.value === themeId)?.value ?? LightTheme.id;
+
+ return (
+
+ );
+}
+
+function SystemThemePreferences() {
+ const themeNames = useThemeNames();
+ const themes = useThemes();
+ const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
+ const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
+
+ const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
+ const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
+ const lightThemeOptions = makeThemeOptions(lightThemes, themeNames);
+ const darkThemeOptions = makeThemeOptions(darkThemes, themeNames);
+
+ const selectedLightThemeId =
+ lightThemeOptions.find((theme) => theme.value === lightThemeId)?.value ?? LightTheme.id;
+ const selectedDarkThemeId =
+ darkThemeOptions.find((theme) => theme.value === darkThemeId)?.value ?? DarkTheme.id;
+
+ return (
+
+
+ }
+ />
+
+ }
+ />
+
+ );
+}
+
+function ClassicThemeSection({ onBrowseCatalog }: { onBrowseCatalog: () => void }) {
+ const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
+
+ return (
+
+
+ }
+ />
+ {systemTheme && }
+
+
+
+ }
+ />
+
+
+
+
+ Browse catalog…
+
+ }
+ />
+
+
+ );
+}
+
+function RemoteCatalogThemeSection({
+ onBrowseOpenChange,
+}: {
+ onBrowseOpenChange?: (open: boolean) => void;
+}) {
+ return ;
+}
+
+export function ThemeAppearanceSection({
+ onBrowseOpenChange,
+}: {
+ onBrowseOpenChange?: (open: boolean) => void;
+} = {}) {
+ const store = useStore();
+ const [onboardingDone] = useSetting(settingsAtom, 'themeCatalogOnboardingDone');
+ const [catalogEnabled] = useSetting(settingsAtom, 'themeRemoteCatalogEnabled');
+
+ useEffect(() => {
+ if (!catalogEnabled) onBrowseOpenChange?.(false);
+ }, [catalogEnabled, onBrowseOpenChange]);
+
+ const completeOnboarding = useCallback(
+ (enabled: boolean) => {
+ const next = { ...store.get(settingsAtom), themeCatalogOnboardingDone: true };
+ next.themeRemoteCatalogEnabled = enabled;
+ store.set(settingsAtom, next);
+ },
+ [store]
+ );
+
+ const { dialog } = useThemeCatalogOnboardingGate(onboardingDone, completeOnboarding);
+
+ const patchSettings = usePatchSettings();
+ const enableCatalog = useCallback(() => {
+ patchSettings({ themeRemoteCatalogEnabled: true, themeCatalogOnboardingDone: true });
+ }, [patchSettings]);
+
+ return (
+
+ Theme
+ {dialog}
+ {catalogEnabled ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx b/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx
new file mode 100644
index 000000000..954c87799
--- /dev/null
+++ b/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx
@@ -0,0 +1,126 @@
+import { useCallback, useEffect, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ config,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+} from 'folds';
+
+import { stopPropagation } from '$utils/keyboard';
+
+type ThemeCatalogOnboardingProps = {
+ open: boolean;
+ onEnable: () => void;
+ onDecline: () => void;
+};
+
+export function ThemeCatalogOnboarding({ open, onEnable, onDecline }: ThemeCatalogOnboardingProps) {
+ return (
+ }>
+
+
+
+
+
+
+ );
+}
+
+export function useThemeCatalogOnboardingGate(
+ onboardingDone: boolean,
+ onComplete: (enabled: boolean) => void
+) {
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ if (!onboardingDone) {
+ setOpen(true);
+ }
+ }, [onboardingDone]);
+
+ const handleEnable = useCallback(() => {
+ setOpen(false);
+ onComplete(true);
+ }, [onComplete]);
+
+ const handleDecline = useCallback(() => {
+ setOpen(false);
+ onComplete(false);
+ }, [onComplete]);
+
+ return {
+ open,
+ dialog: (
+
+ ),
+ };
+}
diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
new file mode 100644
index 000000000..16fc817fb
--- /dev/null
+++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
@@ -0,0 +1,1497 @@
+import { type ChangeEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
+import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
+import { copyToClipboard } from '$utils/dom';
+import { useQuery } from '@tanstack/react-query';
+import {
+ Box,
+ Button,
+ Chip,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Scroll,
+ Spinner,
+ Switch,
+ Text,
+ config,
+ toRem,
+} from 'folds';
+import { useClientConfig } from '$hooks/useClientConfig';
+import { ThemeKind } from '$hooks/useTheme';
+import { useSetting } from '$state/hooks/settings';
+import {
+ settingsAtom,
+ type Settings,
+ type ThemeRemoteFavorite,
+ type ThemeRemoteTweakFavorite,
+} from '$state/settings';
+import { SequenceCardStyle } from '$features/settings/styles.css';
+import { SequenceCard } from '$components/sequence-card';
+import { SettingTile } from '$components/setting-tile';
+import { ThemePreviewCard } from '$components/theme/ThemePreviewCard';
+import { usePatchSettings } from './themeSettingsPatch';
+import { ThemeImportModal } from './ThemeImportModal';
+import { getCachedThemeCss, putCachedThemeCss } from '../../../theme/cache';
+import {
+ fetchThemeCatalogBundle,
+ type ThemePair,
+ type TweakCatalogEntry,
+} from '../../../theme/catalog';
+import { isLocalImportBundledUrl, isLocalImportThemeUrl } from '../../../theme/localImportUrls';
+import { isThirdPartyThemeUrl } from '../../../theme/themeApproval';
+import { themeCatalogListingBaseUrl } from '../../../theme/catalogDefaults';
+import {
+ extractFullThemeUrlFromPreview,
+ parseSableThemeMetadata,
+ parseSableTweakMetadata,
+ type SableThemeContrast,
+} from '../../../theme/metadata';
+import { previewUrlFromFullThemeUrl } from '../../../theme/previewUrls';
+
+export type CatalogPreviewRow = ThemePair & {
+ previewText: string;
+ displayName: string;
+ author?: string;
+ kind: ThemeKind;
+ contrast: SableThemeContrast;
+ tags: string[];
+ fullInstallUrl: string;
+};
+
+export type LocalPreviewRow = ThemeRemoteFavorite & {
+ previewUrl: string;
+ previewText: string;
+ displayName: string;
+ author?: string;
+ contrast: SableThemeContrast;
+ tags: string[];
+ importedLocal?: boolean;
+};
+
+export type CatalogTweakRow = TweakCatalogEntry & {
+ fullCssText: string;
+ displayName: string;
+ description?: string;
+ author?: string;
+ tags: string[];
+};
+
+export type LocalTweakRow = ThemeRemoteTweakFavorite & {
+ fullCssText: string;
+ description?: string;
+ author?: string;
+ tags: string[];
+};
+
+export type ThemeCatalogSettingsMode = 'full' | 'local' | 'chat' | 'remote' | 'appearance';
+
+export { usePatchSettings } from './themeSettingsPatch';
+
+type ThemeCatalogSettingsProps = {
+ mode?: ThemeCatalogSettingsMode;
+ onBrowseOpenChange?: (open: boolean) => void;
+};
+
+type CatalogTweakCardProps = {
+ displayName: string;
+ description: string;
+ copyUrl?: string;
+ thirdPartyChip: boolean;
+ isFavorited: boolean;
+ onToggleFavorite: () => void | Promise;
+ isOn: boolean;
+ onSetApplied: (v: boolean) => void;
+};
+
+function CatalogTweakCard({
+ displayName,
+ description,
+ copyUrl,
+ thirdPartyChip,
+ isFavorited,
+ onToggleFavorite,
+ isOn,
+ onSetApplied,
+}: CatalogTweakCardProps) {
+ const [copied, setCopied] = useTimeoutToggle();
+ const handleCopy = useCallback(async () => {
+ if (!copyUrl) return;
+ if (await copyToClipboard(copyUrl)) setCopied();
+ }, [copyUrl, setCopied]);
+
+ return (
+
+
+
+ {displayName}
+
+ {description}
+
+
+
+ {thirdPartyChip && (
+
+ Third-party URL
+
+ )}
+ {copyUrl && (
+ {
+ handleCopy().catch(() => undefined);
+ }}
+ >
+
+
+ )}
+ {
+ Promise.resolve(onToggleFavorite()).catch(() => undefined);
+ }}
+ >
+
+
+
+
+
+
+ );
+}
+
+type SavedTweakRowProps = {
+ displayName: string;
+ description: string;
+ copyUrl?: string;
+ thirdPartyChip: boolean;
+ isOn: boolean;
+ onRemove: () => void;
+ onSetApplied: (v: boolean) => void;
+};
+
+function SavedTweakRow({
+ displayName,
+ description,
+ copyUrl,
+ thirdPartyChip,
+ isOn,
+ onRemove,
+ onSetApplied,
+}: SavedTweakRowProps) {
+ const [copied, setCopied] = useTimeoutToggle();
+ const handleCopy = useCallback(async () => {
+ if (!copyUrl) return;
+ if (await copyToClipboard(copyUrl)) setCopied();
+ }, [copyUrl, setCopied]);
+
+ return (
+
+
+
+ {displayName}
+
+ {description}
+
+
+
+ {thirdPartyChip && (
+
+ Third-party URL
+
+ )}
+ {copyUrl && (
+ {
+ handleCopy().catch(() => undefined);
+ }}
+ >
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+export function ThemeCatalogSettings({
+ mode = 'full',
+ onBrowseOpenChange,
+}: ThemeCatalogSettingsProps) {
+ const clientConfig = useClientConfig();
+ const patchSettings = usePatchSettings();
+ const configBase = clientConfig.themeCatalogBaseUrl?.trim();
+ const catalogBase = themeCatalogListingBaseUrl(configBase);
+ const catalogManifestUrl = clientConfig.themeCatalogManifestUrl?.trim() || undefined;
+
+ const isAppearanceMode = mode === 'appearance';
+ const [browseOpen, setBrowseOpen] = useState(false);
+ const [importModalOpen, setImportModalOpen] = useState(false);
+
+ useEffect(() => {
+ if (isAppearanceMode) {
+ onBrowseOpenChange?.(browseOpen);
+ }
+ }, [browseOpen, isAppearanceMode, onBrowseOpenChange]);
+
+ const isRemoteMode = mode === 'remote' || mode === 'full' || (isAppearanceMode && browseOpen);
+ const isChatMode = mode === 'chat' || mode === 'full' || (isAppearanceMode && !browseOpen);
+ const showAssignmentChrome =
+ mode === 'full' || mode === 'local' || (isAppearanceMode && !browseOpen);
+ const showSavedLibrary =
+ (mode === 'full' || mode === 'local' || isAppearanceMode) && !(isAppearanceMode && browseOpen);
+
+ const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites');
+ const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites');
+ const [enabledTweakFullUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls');
+ const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
+ const [manualRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl');
+ const [lightRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl');
+ const [darkRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl');
+ const [chatAny, setChatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl');
+
+ const [themeSearch, setThemeSearch] = useState('');
+ const [tweakSearch, setTweakSearch] = useState('');
+ const [kindFilter, setKindFilter] = useState<'all' | 'light' | 'dark'>('all');
+ const [contrastFilter, setContrastFilter] = useState<'all' | SableThemeContrast>('all');
+ const [tweakApplyFilter, setTweakApplyFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
+
+ const onThemeSearchChange: ChangeEventHandler = (e) =>
+ setThemeSearch(e.target.value);
+ const onTweakSearchChange: ChangeEventHandler = (e) =>
+ setTweakSearch(e.target.value);
+
+ const activeUrls = useMemo(
+ () =>
+ [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl].filter((u): u is string =>
+ Boolean(u && u.trim().length > 0)
+ ),
+ [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 clearAssignmentsIfMatch = useCallback(
+ (fullUrl: string) => {
+ const partial: Partial = {};
+ if (lightRemoteFullUrl === fullUrl) {
+ partial.themeRemoteLightFullUrl = undefined;
+ partial.themeRemoteLightKind = undefined;
+ }
+ if (darkRemoteFullUrl === fullUrl) {
+ partial.themeRemoteDarkFullUrl = undefined;
+ partial.themeRemoteDarkKind = undefined;
+ }
+ if (manualRemoteFullUrl === fullUrl) {
+ partial.themeRemoteManualFullUrl = undefined;
+ partial.themeRemoteManualKind = undefined;
+ }
+ return partial;
+ },
+ [darkRemoteFullUrl, lightRemoteFullUrl, manualRemoteFullUrl]
+ );
+
+ const catalogQuery = useQuery({
+ queryKey: ['theme-catalog-bundle', catalogBase, catalogManifestUrl ?? ''],
+ queryFn: () => fetchThemeCatalogBundle(catalogBase, { manifestUrl: catalogManifestUrl }),
+ enabled: isRemoteMode,
+ staleTime: 5 * 60_000,
+ });
+
+ const previewsQuery = useQuery({
+ queryKey: [
+ 'theme-catalog-previews',
+ catalogBase,
+ catalogQuery.data?.themes?.map((p) => p.previewUrl).join('|') ?? '',
+ ],
+ queryFn: async (): Promise => {
+ const pairs = catalogQuery.data?.themes ?? [];
+ const rows = await Promise.all(
+ pairs.map(async (pair) => {
+ const res = await fetch(pair.previewUrl, { mode: 'cors' });
+ const previewText = res.ok ? await res.text() : '';
+ const meta = parseSableThemeMetadata(previewText);
+ const fullFromMeta = extractFullThemeUrlFromPreview(previewText);
+ const fullInstallUrl =
+ fullFromMeta && /^https:\/\//i.test(fullFromMeta) ? fullFromMeta : pair.fullUrl;
+ const kind = meta.kind ?? ThemeKind.Light;
+ const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low';
+ return {
+ ...pair,
+ previewText,
+ displayName: meta.name?.trim() || pair.basename,
+ author: meta.author?.trim() || undefined,
+ kind,
+ contrast,
+ tags: meta.tags ?? [],
+ fullInstallUrl,
+ };
+ })
+ );
+ return rows;
+ },
+ enabled: isRemoteMode && Boolean(catalogQuery.data?.themes?.length),
+ staleTime: 10 * 60_000,
+ });
+
+ const tweakDetailsQuery = useQuery({
+ queryKey: [
+ 'theme-catalog-tweak-details',
+ catalogBase,
+ catalogQuery.data?.tweaks?.map((t) => t.fullUrl).join('|') ?? '',
+ ],
+ queryFn: async (): Promise => {
+ const tweaks = catalogQuery.data?.tweaks ?? [];
+ const rows = await Promise.all(
+ tweaks.map(async (entry) => {
+ try {
+ let text: string;
+ if (isLocalImportBundledUrl(entry.fullUrl)) {
+ text = (await getCachedThemeCss(entry.fullUrl)) ?? '';
+ } else {
+ const res = await fetch(entry.fullUrl, { mode: 'cors' });
+ text = res.ok ? await res.text() : '';
+ }
+ const meta = parseSableTweakMetadata(text);
+ return {
+ ...entry,
+ fullCssText: text,
+ displayName: meta.name?.trim() || entry.basename,
+ description: meta.description?.trim() || undefined,
+ author: meta.author?.trim() || undefined,
+ tags: meta.tags ?? [],
+ };
+ } catch {
+ return {
+ ...entry,
+ fullCssText: '',
+ displayName: entry.basename,
+ tags: [],
+ };
+ }
+ })
+ );
+ return rows;
+ },
+ enabled: isRemoteMode && Boolean(catalogQuery.data?.tweaks?.length),
+ staleTime: 10 * 60_000,
+ });
+
+ const filteredTweakRows = useMemo(() => {
+ const rows = tweakDetailsQuery.data ?? [];
+ const q = tweakSearch.trim().toLowerCase();
+ if (!q) return rows;
+ return rows.filter((row) => {
+ const hay =
+ `${row.displayName} ${row.basename} ${row.description ?? ''} ${row.tags.join(' ')}`.toLowerCase();
+ return hay.includes(q);
+ });
+ }, [tweakDetailsQuery.data, tweakSearch]);
+
+ const catalogTweaksAfterApplyFilter = useMemo(() => {
+ if (tweakApplyFilter === 'all') return filteredTweakRows;
+ if (tweakApplyFilter === 'enabled') {
+ return filteredTweakRows.filter((r) => enabledTweakFullUrls.includes(r.fullUrl));
+ }
+ return filteredTweakRows.filter((r) => !enabledTweakFullUrls.includes(r.fullUrl));
+ }, [filteredTweakRows, enabledTweakFullUrls, tweakApplyFilter]);
+
+ const filteredRows = useMemo(() => {
+ const rows = previewsQuery.data ?? [];
+ const q = themeSearch.trim().toLowerCase();
+ return rows.filter((row) => {
+ if (kindFilter !== 'all') {
+ const want = kindFilter === 'dark' ? ThemeKind.Dark : ThemeKind.Light;
+ if (row.kind !== want) return false;
+ }
+ if (contrastFilter !== 'all' && row.contrast !== contrastFilter) return false;
+ if (q) {
+ const hay = `${row.displayName} ${row.basename} ${row.tags.join(' ')}`.toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ return true;
+ });
+ }, [previewsQuery.data, themeSearch, kindFilter, contrastFilter]);
+
+ const localPreviewsQuery = useQuery({
+ queryKey: ['theme-local-previews', favorites.map((f) => f.fullUrl).join('|')],
+ enabled: showSavedLibrary && favorites.length > 0,
+ staleTime: 10 * 60_000,
+ queryFn: async (): Promise => {
+ const rows = await Promise.all(
+ favorites.map(async (fav) => {
+ const previewUrl = previewUrlFromFullThemeUrl(fav.fullUrl);
+ if (!previewUrl) return undefined;
+
+ try {
+ let previewText: string;
+ if (isLocalImportThemeUrl(previewUrl)) {
+ previewText = (await getCachedThemeCss(previewUrl)) ?? '';
+ } else {
+ const res = await fetch(previewUrl, { mode: 'cors' });
+ if (!res.ok) return undefined;
+ previewText = await res.text();
+ }
+ const meta = parseSableThemeMetadata(previewText);
+ const displayName = meta.name?.trim() || fav.displayName || fav.basename;
+ const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low';
+ const authorTrim = meta.author?.trim();
+ const row: LocalPreviewRow = {
+ ...fav,
+ previewUrl,
+ previewText,
+ displayName,
+ contrast,
+ tags: meta.tags ?? [],
+ importedLocal: fav.importedLocal,
+ ...(authorTrim ? { author: authorTrim } : {}),
+ };
+ return row;
+ } catch {
+ return undefined;
+ }
+ })
+ );
+
+ return rows.filter((r): r is LocalPreviewRow => Boolean(r));
+ },
+ });
+
+ const localTweaksQuery = useQuery({
+ queryKey: ['theme-local-tweaks', tweakFavorites.map((f) => f.fullUrl).join('|')],
+ enabled: showSavedLibrary && tweakFavorites.length > 0,
+ staleTime: 10 * 60_000,
+ queryFn: async (): Promise => {
+ 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();
+ }
+ const meta = parseSableTweakMetadata(text);
+ const authorTrim = meta.author?.trim();
+ const row: LocalTweakRow = {
+ ...fav,
+ fullCssText: text,
+ displayName: meta.name?.trim() || fav.displayName || fav.basename,
+ description: meta.description?.trim() || undefined,
+ tags: meta.tags ?? [],
+ importedLocal: fav.importedLocal,
+ ...(authorTrim ? { author: authorTrim } : {}),
+ };
+ return row;
+ } catch {
+ return undefined;
+ }
+ })
+ );
+ return rows.filter((r): r is LocalTweakRow => Boolean(r));
+ },
+ });
+
+ const removeFavorite = useCallback(
+ (fullUrl: string) => {
+ const nextFavorites = favorites.filter((f) => f.fullUrl !== fullUrl);
+ const cleared = clearAssignmentsIfMatch(fullUrl);
+ const nextActive = [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl]
+ .filter((u): u is string => Boolean(u && u.trim().length > 0))
+ .filter((u) => u !== fullUrl);
+ patchSettings({
+ ...cleared,
+ themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ });
+ },
+ [
+ clearAssignmentsIfMatch,
+ darkRemoteFullUrl,
+ favorites,
+ lightRemoteFullUrl,
+ manualRemoteFullUrl,
+ patchSettings,
+ pruneFavorites,
+ ]
+ );
+
+ const applyFavoriteToLight = useCallback(
+ (row: LocalPreviewRow) => {
+ patchSettings({
+ themeRemoteLightFullUrl: row.fullUrl,
+ themeRemoteLightKind: row.kind,
+ });
+ },
+ [patchSettings]
+ );
+
+ const applyFavoriteToDark = useCallback(
+ (row: LocalPreviewRow) => {
+ patchSettings({
+ themeRemoteDarkFullUrl: row.fullUrl,
+ themeRemoteDarkKind: row.kind,
+ });
+ },
+ [patchSettings]
+ );
+
+ const applyFavoriteToManual = useCallback(
+ (row: LocalPreviewRow) => {
+ patchSettings({
+ themeRemoteManualFullUrl: row.fullUrl,
+ themeRemoteManualKind: row.kind,
+ });
+ },
+ [patchSettings]
+ );
+
+ const useBuiltinForLightSlot = useCallback(
+ () =>
+ patchSettings({
+ themeRemoteLightFullUrl: undefined,
+ themeRemoteLightKind: undefined,
+ }),
+ [patchSettings]
+ );
+
+ const useBuiltinForDarkSlot = useCallback(
+ () =>
+ patchSettings({
+ themeRemoteDarkFullUrl: undefined,
+ themeRemoteDarkKind: undefined,
+ }),
+ [patchSettings]
+ );
+
+ const useBuiltinForManualLight = useCallback(
+ () =>
+ patchSettings({
+ themeRemoteManualFullUrl: undefined,
+ themeRemoteManualKind: undefined,
+ themeId: 'light-theme',
+ }),
+ [patchSettings]
+ );
+
+ const useBuiltinForManualDark = useCallback(
+ () =>
+ patchSettings({
+ themeRemoteManualFullUrl: undefined,
+ themeRemoteManualKind: undefined,
+ themeId: 'dark-theme',
+ }),
+ [patchSettings]
+ );
+
+ const prefetchFull = useCallback(async (url: string): Promise => {
+ try {
+ if (isLocalImportBundledUrl(url)) {
+ const cached = await getCachedThemeCss(url);
+ return Boolean(cached);
+ }
+ const res = await fetch(url, { mode: 'cors' });
+ if (!res.ok) return false;
+ const text = await res.text();
+ await putCachedThemeCss(url, text);
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ const toggleFavorite = useCallback(
+ async (row: CatalogPreviewRow) => {
+ const existing = favorites.find((f: ThemeRemoteFavorite) => f.fullUrl === row.fullInstallUrl);
+ if (existing) {
+ const nextFavorites = favorites.filter((f) => f.fullUrl !== row.fullInstallUrl);
+ const cleared = clearAssignmentsIfMatch(row.fullInstallUrl);
+ const nextActive = activeUrls.filter((u) => u !== row.fullInstallUrl);
+ patchSettings({
+ ...cleared,
+ themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ });
+ return;
+ }
+ const ok = await prefetchFull(row.fullInstallUrl);
+ if (!ok) return;
+ const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light';
+ const next: ThemeRemoteFavorite = {
+ fullUrl: row.fullInstallUrl,
+ displayName: row.displayName,
+ basename: row.basename,
+ kind,
+ pinned: true,
+ };
+ patchSettings({
+ themeRemoteFavorites: [...favorites, next],
+ });
+ },
+ [activeUrls, clearAssignmentsIfMatch, favorites, patchSettings, prefetchFull, pruneFavorites]
+ );
+
+ const installFromCatalogLight = useCallback(
+ async (row: CatalogPreviewRow) => {
+ const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light';
+ const nextActive = Array.from(
+ new Set(
+ [manualRemoteFullUrl, darkRemoteFullUrl, row.fullInstallUrl].filter(Boolean) as string[]
+ )
+ );
+
+ let nextFavorites = favorites;
+ const existing = favorites.find((f) => f.fullUrl === row.fullInstallUrl);
+ if (!existing) {
+ const ok = await prefetchFull(row.fullInstallUrl);
+ if (!ok) return;
+ nextFavorites = [
+ ...favorites,
+ {
+ fullUrl: row.fullInstallUrl,
+ displayName: row.displayName,
+ basename: row.basename,
+ kind,
+ pinned: false,
+ },
+ ];
+ }
+
+ patchSettings({
+ themeRemoteLightFullUrl: row.fullInstallUrl,
+ themeRemoteLightKind: kind,
+ themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ });
+ },
+ [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites]
+ );
+
+ const installFromCatalogDark = useCallback(
+ async (row: CatalogPreviewRow) => {
+ const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light';
+ const nextActive = Array.from(
+ new Set(
+ [manualRemoteFullUrl, lightRemoteFullUrl, row.fullInstallUrl].filter(Boolean) as string[]
+ )
+ );
+
+ let nextFavorites = favorites;
+ const existing = favorites.find((f) => f.fullUrl === row.fullInstallUrl);
+ if (!existing) {
+ const ok = await prefetchFull(row.fullInstallUrl);
+ if (!ok) return;
+ nextFavorites = [
+ ...favorites,
+ {
+ fullUrl: row.fullInstallUrl,
+ displayName: row.displayName,
+ basename: row.basename,
+ kind,
+ pinned: false,
+ },
+ ];
+ }
+
+ patchSettings({
+ themeRemoteDarkFullUrl: row.fullInstallUrl,
+ themeRemoteDarkKind: kind,
+ themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ });
+ },
+ [
+ favorites,
+ lightRemoteFullUrl,
+ manualRemoteFullUrl,
+ patchSettings,
+ prefetchFull,
+ pruneFavorites,
+ ]
+ );
+
+ const installFromCatalogManual = useCallback(
+ async (row: CatalogPreviewRow) => {
+ const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light';
+ const nextActive = Array.from(
+ new Set(
+ [lightRemoteFullUrl, darkRemoteFullUrl, row.fullInstallUrl].filter(Boolean) as string[]
+ )
+ );
+
+ let nextFavorites = favorites;
+ const existing = favorites.find((f) => f.fullUrl === row.fullInstallUrl);
+ if (!existing) {
+ const ok = await prefetchFull(row.fullInstallUrl);
+ if (!ok) return;
+ nextFavorites = [
+ ...favorites,
+ {
+ fullUrl: row.fullInstallUrl,
+ displayName: row.displayName,
+ basename: row.basename,
+ kind,
+ pinned: false,
+ },
+ ];
+ }
+
+ patchSettings({
+ themeRemoteManualFullUrl: row.fullInstallUrl,
+ themeRemoteManualKind: kind,
+ themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive),
+ });
+ },
+ [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites]
+ );
+
+ const clearRemote = useCallback(() => {
+ patchSettings({
+ themeRemoteManualFullUrl: undefined,
+ themeRemoteManualKind: undefined,
+ themeRemoteLightFullUrl: undefined,
+ themeRemoteLightKind: undefined,
+ themeRemoteDarkFullUrl: undefined,
+ themeRemoteDarkKind: undefined,
+ });
+ }, [patchSettings]);
+
+ const setTweakApplied = useCallback(
+ async (fullUrl: string, apply: boolean, hint?: { displayName?: string; basename?: string }) => {
+ const trimmed = fullUrl.trim();
+ if (!trimmed) return;
+
+ if (apply) {
+ const ok = await prefetchFull(trimmed);
+ if (!ok) return;
+ const nextEnabled = enabledTweakFullUrls.includes(trimmed)
+ ? [...enabledTweakFullUrls]
+ : [...enabledTweakFullUrls, trimmed];
+ const nextFavs = [...tweakFavorites];
+ if (!nextFavs.some((f) => f.fullUrl === trimmed)) {
+ const cached = (await getCachedThemeCss(trimmed)) ?? '';
+ const meta = parseSableTweakMetadata(cached);
+ const base =
+ trimmed
+ .replace(/\.sable\.css(\?.*)?$/i, '')
+ .split('/')
+ .pop() ?? 'tweak';
+ nextFavs.push({
+ fullUrl: trimmed,
+ displayName: hint?.displayName ?? meta.name?.trim() ?? base,
+ basename: hint?.basename ?? meta.id?.trim() ?? base,
+ pinned: false,
+ });
+ }
+ patchSettings({
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ });
+ } else {
+ const nextEnabled = enabledTweakFullUrls.filter((u) => u !== trimmed);
+ patchSettings({
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ themeRemoteTweakFavorites: pruneTweakFavorites(tweakFavorites, nextEnabled),
+ });
+ }
+ },
+ [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites]
+ );
+
+ const toggleCatalogTweakFavorite = useCallback(
+ async (row: CatalogTweakRow) => {
+ const existing = tweakFavorites.find((f) => f.fullUrl === row.fullUrl);
+ if (existing) {
+ const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== row.fullUrl);
+ const nextEnabled = enabledTweakFullUrls.filter((u) => u !== row.fullUrl);
+ patchSettings({
+ themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ });
+ return;
+ }
+ const ok = await prefetchFull(row.fullUrl);
+ if (!ok) return;
+ const next: ThemeRemoteTweakFavorite = {
+ fullUrl: row.fullUrl,
+ displayName: row.displayName,
+ basename: row.basename,
+ pinned: true,
+ };
+ patchSettings({
+ themeRemoteTweakFavorites: pruneTweakFavorites(
+ [...tweakFavorites, next],
+ enabledTweakFullUrls
+ ),
+ });
+ },
+ [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites]
+ );
+
+ const removeTweakFavorite = useCallback(
+ (fullUrl: string) => {
+ const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== fullUrl);
+ const nextEnabled = enabledTweakFullUrls.filter((u) => u !== fullUrl);
+ patchSettings({
+ themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled),
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ });
+ },
+ [enabledTweakFullUrls, patchSettings, pruneTweakFavorites, tweakFavorites]
+ );
+
+ const catalogBundle = catalogQuery.data;
+ const catalogThemeCount = catalogBundle?.themes.length ?? 0;
+ const catalogTweakCount = catalogBundle?.tweaks.length ?? 0;
+ const catalogHasEntries = catalogThemeCount + catalogTweakCount > 0;
+
+ return (
+
+ {showAssignmentChrome && (
+ <>
+
+ }
+ />
+
+ {systemTheme ? (
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+
+ Clear
+
+ }
+ />
+
+ >
+ )}
+
+ {showSavedLibrary && (
+ <>
+
+ Saved themes
+ {localPreviewsQuery.isPending && favorites.length > 0 && (
+
+
+ Loading local previews…
+
+ )}
+
+ {favorites.length === 0 && (
+
+ No saved themes yet. Star themes in the catalog to download them locally.
+
+ )}
+
+ {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 = (
+ <>
+ {line1}
+ {line2 ? (
+ <>
+
+ {line2}
+ >
+ ) : null}
+ >
+ );
+ return (
+ removeFavorite(row.fullUrl)}
+ 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. Star 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 ? (
+
+ 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)}
+ onSetApplied={(v) =>
+ setTweakApplied(row.fullUrl, v, {
+ displayName: row.displayName,
+ basename: row.basename,
+ })
+ }
+ />
+ );
+ })
+ )}
+
+ )}
+
+ >
+ )}
+
+ {isAppearanceMode && !browseOpen && (
+ <>
+
+ setBrowseOpen(true)}
+ >
+ Browse catalog…
+
+ }
+ />
+
+
+
+ setImportModalOpen(true)}
+ >
+ Import…
+
+ }
+ />
+
+
+ setImportModalOpen(false)} />
+ >
+ )}
+
+ {isRemoteMode && (
+ <>
+ {!isAppearanceMode && Browse catalog}
+
+ {(catalogQuery.isPending ||
+ catalogQuery.isError ||
+ (catalogQuery.isSuccess && catalogHasEntries)) && (
+
+ {isAppearanceMode && browseOpen && (
+ setBrowseOpen(false)}
+ >
+ Back
+
+ }
+ />
+ )}
+
+ {(catalogQuery.isPending || catalogQuery.isError) && (
+
+ {catalogQuery.isPending && (
+
+
+ Loading catalog…
+
+ )}
+ {catalogQuery.isError && (
+
+ {catalogQuery.error?.message ?? 'Failed to load catalog'}
+
+ )}
+
+ )}
+
+ {catalogQuery.isSuccess && catalogHasEntries && (
+
+ {catalogThemeCount > 0 && previewsQuery.isPending && (
+
+
+ Loading previews…
+
+ )}
+
+ {catalogTweakCount > 0 && tweakDetailsQuery.isPending && (
+
+
+ Loading tweaks…
+
+ )}
+
+ {catalogThemeCount > 0 && previewsQuery.isSuccess && (
+
+ Themes
+
+
+ Kind:
+ {(['all', 'light', 'dark'] as const).map((k) => (
+ setKindFilter(k)}
+ >
+ {k === 'all' ? 'All' : k}
+
+ ))}
+ Contrast:
+ {(['all', 'low', 'high'] as const).map((c) => (
+ setContrastFilter(c)}
+ >
+ {c === 'all' ? 'All' : c}
+
+ ))}
+
+
+
+
+ {filteredRows.map((row) => {
+ const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme';
+ const kindLabel = row.kind === ThemeKind.Dark ? 'Dark' : 'Light';
+ const isFav = favorites.some((f) => f.fullUrl === row.fullInstallUrl);
+ 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 ? (
+ <>
+
+ {line2}
+ >
+ ) : null}
+ >
+ );
+ return (
+ toggleFavorite(row)}
+ systemTheme={systemTheme}
+ onApplyLight={
+ systemTheme ? () => installFromCatalogLight(row) : undefined
+ }
+ onApplyDark={
+ systemTheme ? () => installFromCatalogDark(row) : undefined
+ }
+ onApplyManual={
+ !systemTheme ? () => installFromCatalogManual(row) : undefined
+ }
+ isAppliedLight={lightRemoteFullUrl === row.fullInstallUrl}
+ isAppliedDark={darkRemoteFullUrl === row.fullInstallUrl}
+ isAppliedManual={manualRemoteFullUrl === row.fullInstallUrl}
+ />
+ );
+ })}
+
+
+ {filteredRows.length === 0 && (
+
+ No themes match filters.
+
+ )}
+
+
+
+ )}
+
+ {catalogTweakCount > 0 && tweakDetailsQuery.isSuccess && (
+
+ Tweaks
+
+
+ Status:
+ {(['all', 'enabled', 'disabled'] as const).map((f) => (
+ setTweakApplyFilter(f)}
+ >
+
+ {
+ {
+ all: 'All',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ }[f]
+ }
+
+
+ ))}
+
+
+
+ {catalogTweaksAfterApplyFilter.map((row) => {
+ const isFav = tweakFavorites.some((f) => f.fullUrl === row.fullUrl);
+ 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 (
+ toggleCatalogTweakFavorite(row)}
+ isOn={isOn}
+ onSetApplied={(v) =>
+ setTweakApplied(row.fullUrl, v, {
+ displayName: row.displayName,
+ basename: row.basename,
+ })
+ }
+ />
+ );
+ })}
+ {filteredTweakRows.length === 0 && (
+
+ No tweaks match your search.
+
+ )}
+ {filteredTweakRows.length > 0 &&
+ catalogTweaksAfterApplyFilter.length === 0 && (
+
+ No tweaks match this status filter.
+
+ )}
+
+
+
+ )}
+
+ )}
+
+ )}
+ >
+ )}
+
+ {isChatMode && (
+
+ }
+ />
+
+ )}
+
+ );
+}
diff --git a/src/app/features/settings/cosmetics/ThemeImportModal.tsx b/src/app/features/settings/cosmetics/ThemeImportModal.tsx
new file mode 100644
index 000000000..d6abbb1c3
--- /dev/null
+++ b/src/app/features/settings/cosmetics/ThemeImportModal.tsx
@@ -0,0 +1,364 @@
+import { type ChangeEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ config,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ toRem,
+} from 'folds';
+
+import { useSetting } from '$state/hooks/settings';
+import {
+ settingsAtom,
+ type ThemeRemoteFavorite,
+ type ThemeRemoteTweakFavorite,
+} from '$state/settings';
+import { stopPropagation } from '$utils/keyboard';
+
+import { SequenceCardStyle } from '$features/settings/styles.css';
+import { SequenceCard } from '$components/sequence-card';
+import {
+ processImportedHttpsUrl,
+ processPastedOrUploadedCss,
+ type ProcessedThemeImport,
+} from '../../../theme/processThemeImport';
+
+import { usePatchSettings } from './themeSettingsPatch';
+
+type ThemeImportModalProps = {
+ open: boolean;
+ onClose: () => void;
+};
+
+export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) {
+ const patchSettings = usePatchSettings();
+ const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites');
+ const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites');
+ const [enabledTweakFullUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls');
+ const [manualRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl');
+ const [lightRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl');
+ const [darkRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl');
+
+ const [importUrl, setImportUrl] = useState('');
+ const [importPaste, setImportPaste] = useState('');
+ const [uploadedFileCss, setUploadedFileCss] = useState(null);
+ const [importError, setImportError] = useState(null);
+ const [importBusy, setImportBusy] = useState(false);
+ const [importFileName, setImportFileName] = useState(undefined);
+ const importFileRef = useRef(null);
+
+ const activeUrls = useMemo(
+ () =>
+ [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl].filter((u): u is string =>
+ Boolean(u && u.trim().length > 0)
+ ),
+ [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));
+ },
+ []
+ );
+
+ useEffect(() => {
+ if (!open) {
+ setImportUrl('');
+ setImportPaste('');
+ setUploadedFileCss(null);
+ setImportError(null);
+ setImportFileName(undefined);
+ if (importFileRef.current) importFileRef.current.value = '';
+ }
+ }, [open]);
+
+ const onImportUrlChange: ChangeEventHandler = (e) =>
+ setImportUrl(e.target.value);
+
+ const onImportPasteChange: ChangeEventHandler = (e) =>
+ setImportPaste(e.target.value);
+
+ const addImportedResult = useCallback(
+ (r: Extract) => {
+ if (r.role === 'tweak') {
+ const existing = tweakFavorites.find((f) => f.fullUrl === r.fullUrl);
+ if (existing) {
+ setImportError('That tweak is already saved.');
+ return;
+ }
+ const next: ThemeRemoteTweakFavorite = {
+ fullUrl: r.fullUrl,
+ displayName: r.displayName,
+ basename: r.basename,
+ pinned: true,
+ importedLocal: r.importedLocal,
+ };
+ const nextEnabled = enabledTweakFullUrls.includes(r.fullUrl)
+ ? [...enabledTweakFullUrls]
+ : [...enabledTweakFullUrls, r.fullUrl];
+ patchSettings({
+ themeRemoteTweakFavorites: pruneTweakFavorites([...tweakFavorites, next], nextEnabled),
+ themeRemoteEnabledTweakFullUrls: nextEnabled,
+ });
+ onClose();
+ return;
+ }
+
+ const existing = favorites.find((f) => f.fullUrl === r.fullUrl);
+ if (existing) {
+ setImportError('That theme is already saved.');
+ return;
+ }
+ const next: ThemeRemoteFavorite = {
+ fullUrl: r.fullUrl,
+ displayName: r.displayName,
+ basename: r.basename,
+ kind: r.kind,
+ pinned: true,
+ importedLocal: r.importedLocal,
+ };
+ const nextActive = Array.from(
+ new Set(
+ [...activeUrls, r.fullUrl].filter((u): u is string => Boolean(u && u.trim().length > 0))
+ )
+ );
+ patchSettings({
+ themeRemoteFavorites: pruneFavorites([...favorites, next], nextActive),
+ });
+ onClose();
+ },
+ [
+ activeUrls,
+ enabledTweakFullUrls,
+ favorites,
+ onClose,
+ patchSettings,
+ pruneFavorites,
+ pruneTweakFavorites,
+ tweakFavorites,
+ ]
+ );
+
+ const onImportFileChange: ChangeEventHandler = (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setImportFileName(file.name);
+ setImportPaste('');
+ const reader = new FileReader();
+ reader.onload = () => {
+ setUploadedFileCss(typeof reader.result === 'string' ? reader.result : '');
+ };
+ reader.readAsText(file);
+ };
+
+ const handleImportTheme = useCallback(async () => {
+ setImportError(null);
+ const urlTrim = importUrl.trim();
+ if (/^https:\/\//i.test(urlTrim)) {
+ setImportBusy(true);
+ try {
+ const result = await processImportedHttpsUrl(urlTrim);
+ if (!result.ok) {
+ setImportError(result.error);
+ return;
+ }
+ addImportedResult(result);
+ } finally {
+ setImportBusy(false);
+ }
+ return;
+ }
+ const pasted = importPaste.trim();
+ const fromFile = uploadedFileCss?.trim();
+ const cssToImport = fromFile || pasted;
+ if (!cssToImport) {
+ setImportError('Enter an URL, paste CSS, or choose a file.');
+ return;
+ }
+ setImportBusy(true);
+ try {
+ const result = await processPastedOrUploadedCss(
+ cssToImport,
+ fromFile ? importFileName : undefined
+ );
+ if (!result.ok) {
+ setImportError(result.error);
+ return;
+ }
+ addImportedResult(result);
+ } finally {
+ setImportBusy(false);
+ }
+ }, [addImportedResult, importFileName, importPaste, importUrl, uploadedFileCss]);
+
+ const dismissSafe = useCallback(() => {
+ if (importBusy) return;
+ onClose();
+ }, [importBusy, onClose]);
+
+ if (!open) return null;
+
+ return (
+ }>
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx
index ba621537d..16cd306c5 100644
--- a/src/app/features/settings/cosmetics/Themes.test.tsx
+++ b/src/app/features/settings/cosmetics/Themes.test.tsx
@@ -6,6 +6,12 @@ import { Appearance } from './Themes';
type SettingsShape = {
themeId?: string;
useSystemTheme: boolean;
+ themeCatalogOnboardingDone: boolean;
+ themeMigrationDismissed: boolean;
+ themeRemoteCatalogEnabled: boolean;
+ themeRemoteFavorites: unknown[];
+ themeRemoteTweakFavorites: unknown[];
+ themeRemoteEnabledTweakFullUrls: string[];
lightThemeId?: string;
darkThemeId?: string;
useSystemArboriumTheme: boolean;
@@ -61,6 +67,12 @@ beforeEach(() => {
currentSettings = {
themeId: 'silver-theme',
useSystemTheme: true,
+ themeCatalogOnboardingDone: true,
+ themeMigrationDismissed: true,
+ themeRemoteCatalogEnabled: false,
+ themeRemoteFavorites: [],
+ themeRemoteTweakFavorites: [],
+ themeRemoteEnabledTweakFullUrls: [],
lightThemeId: 'cinny-light-theme',
darkThemeId: 'black-theme',
useSystemArboriumTheme: true,
@@ -94,22 +106,24 @@ const getFirstEnabledButton = (name: string) =>
screen.getAllByRole('button', { name }).find((node) => !node.hasAttribute('disabled'));
describe('Appearance settings', () => {
- it('renders Theme, Code Block Theme, and Visual Tweaks as separate sections', () => {
+ it('renders Theme, Display, Code Block Theme, and Visual Tweaks as separate sections', () => {
render();
const themeHeading = screen.getByText('Theme');
+ const displayHeading = screen.getByText('Display');
const codeBlockThemeHeading = screen.getByText('Code Block Theme');
const visualTweaksHeading = screen.getByText('Visual Tweaks');
- expect(themeHeading.compareDocumentPosition(codeBlockThemeHeading)).toBe(
+ expect(themeHeading.compareDocumentPosition(displayHeading)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+ expect(displayHeading.compareDocumentPosition(codeBlockThemeHeading)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(codeBlockThemeHeading.compareDocumentPosition(visualTweaksHeading)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
- expect(screen.getByRole('button', { name: 'Silver' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Cinny Light' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Black' })).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Light' }).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Dracula' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'Dracula' }).at(-1)).toBeDisabled();
@@ -124,7 +138,7 @@ describe('Appearance settings', () => {
render();
- fireEvent.click(screen.getByRole('button', { name: 'Silver' }));
+ fireEvent.click(screen.getAllByRole('button', { name: 'Light' }).at(-1)!);
clickLatestButton('Dark');
fireEvent.click(screen.getByRole('button', { name: 'Dracula' }));
@@ -134,19 +148,6 @@ describe('Appearance settings', () => {
expect(getSetter('arboriumThemeId')).toHaveBeenCalledWith('ayu-light');
});
- it('updates the system theme settings when the chip selectors change', () => {
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Cinny Light' }));
- clickLatestButton('Silver');
-
- fireEvent.click(screen.getByRole('button', { name: 'Black' }));
- clickLatestButton('Dark');
-
- expect(getSetter('lightThemeId')).toHaveBeenCalledWith('silver-theme');
- expect(getSetter('darkThemeId')).toHaveBeenCalledWith('dark-theme');
- });
-
it('updates the system code block theme settings when the chip selectors change', () => {
render();
@@ -169,7 +170,7 @@ describe('Appearance settings', () => {
render();
- expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Light' }).length).toBeGreaterThan(0);
});
it('falls back to the active code block system theme when the stored manual theme id is invalid', () => {
@@ -194,7 +195,7 @@ describe('Appearance settings', () => {
render();
- expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Light' }).length).toBeGreaterThan(0);
+ expect(screen.getAllByRole('button', { name: 'Dark' }).length).toBeGreaterThan(0);
});
});
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index 3e5c8cac1..16d2a0eea 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -11,26 +11,11 @@ import {
getArboriumThemeLabel,
getArboriumThemeOptions,
} from '$plugins/arborium';
-import {
- DarkTheme,
- LightTheme,
- Theme,
- ThemeKind,
- useActiveTheme,
- useSystemThemeKind,
- useThemeNames,
- useThemes,
-} from '$hooks/useTheme';
+import { ThemeKind, useActiveTheme } from '$hooks/useTheme';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { SequenceCardStyle } from '$features/settings/styles.css';
-
-function makeThemeOptions(themes: Theme[], themeNames: Record) {
- return themes.map((theme) => ({
- value: theme.id,
- label: themeNames[theme.id] ?? theme.id,
- }));
-}
+import { ThemeAppearanceSection } from './ThemeAppearanceSection';
function makeArboriumThemeOptions(kind?: 'light' | 'dark') {
const themes = kind
@@ -69,86 +54,6 @@ function ThemeTrigger({
);
}
-function SelectTheme({ disabled }: Readonly<{ disabled?: boolean }>) {
- const themes = useThemes();
- const themeNames = useThemeNames();
- const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
-
- const themeOptions = makeThemeOptions(themes, themeNames);
- const selectedThemeId =
- themeOptions.find((theme) => theme.value === themeId)?.value ?? LightTheme.id;
-
- return (
-
- );
-}
-
-function SystemThemePreferences() {
- const themeKind = useSystemThemeKind();
- const themeNames = useThemeNames();
- const themes = useThemes();
- const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
- const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
-
- const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
- const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
- const lightThemeOptions = makeThemeOptions(lightThemes, themeNames);
- const darkThemeOptions = makeThemeOptions(darkThemes, themeNames);
-
- const selectedLightThemeId =
- lightThemeOptions.find((theme) => theme.value === lightThemeId)?.value ?? LightTheme.id;
- const selectedDarkThemeId =
- darkThemeOptions.find((theme) => theme.value === darkThemeId)?.value ?? DarkTheme.id;
-
- return (
-
- (
-
- )}
- />
- }
- />
- (
-
- )}
- />
- }
- />
-
- );
-}
-
function SelectCodeBlockTheme({ disabled }: Readonly<{ disabled?: boolean }>) {
const activeTheme = useActiveTheme();
const [arboriumThemeId, setArboriumThemeId] = useSetting(settingsAtom, 'arboriumThemeId');
@@ -280,8 +185,7 @@ function CodeBlockThemeSettings() {
);
}
-function ThemeSettings() {
- const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
+function ThemeVisualPreferences() {
const [saturation, setSaturation] = useSetting(settingsAtom, 'saturationLevel');
const [underlineLinks, setUnderlineLinks] = useSetting(settingsAtom, 'underlineLinks');
const [reducedMotion, setReducedMotion] = useSetting(settingsAtom, 'reducedMotion');
@@ -291,31 +195,7 @@ function ThemeSettings() {
return (
- Theme
-
-
- }
- />
- {systemTheme && }
-
-
-
- }
- />
-
+ Display
void;
+} = {}) {
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs');
+ const [themeBrowserOpen, setThemeBrowserOpen] = useState(false);
const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting(
settingsAtom,
'closeFoldersByDefault'
@@ -489,58 +374,70 @@ export function Appearance() {
return (
-
-
-
-
- Visual Tweaks
-
-
- }
- />
-
-
-
- {
+ setThemeBrowserOpen(open);
+ onThemeBrowserOpenChange?.(open);
+ }}
+ />
+ {!themeBrowserOpen && (
+ <>
+
+
+
+
+ Visual Tweaks
+
+
+ }
/>
- }
- />
-
-
-
- }
- />
-
-
-
- } />
-
-
-
- }
- />
-
-
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+ } />
+
+
+
+ }
+ />
+
+
+ >
+ )}
);
}
diff --git a/src/app/features/settings/cosmetics/themeSettingsPatch.ts b/src/app/features/settings/cosmetics/themeSettingsPatch.ts
new file mode 100644
index 000000000..257db7f2a
--- /dev/null
+++ b/src/app/features/settings/cosmetics/themeSettingsPatch.ts
@@ -0,0 +1,15 @@
+import { useCallback } from 'react';
+import { useStore } from 'jotai/react';
+
+import { settingsAtom, type Settings } from '$state/settings';
+
+export function usePatchSettings() {
+ const store = useStore();
+ return useCallback(
+ (partial: Partial) => {
+ const next = { ...store.get(settingsAtom), ...partial };
+ store.set(settingsAtom, next);
+ },
+ [store]
+ );
+}
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..441a7fd49 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -43,6 +43,10 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
+
+ themeCatalogBaseUrl?: string;
+ themeCatalogManifestUrl?: string;
+ themeCatalogApprovedHostPrefixes?: string[];
};
const ClientConfigContext = createContext(null);
@@ -55,6 +59,10 @@ export function useClientConfig(): ClientConfig {
return config;
}
+export function useOptionalClientConfig(): ClientConfig | null {
+ return useContext(ClientConfigContext);
+}
+
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts
index 3492fd185..9ed4b9e2f 100755
--- a/src/app/hooks/useTheme.ts
+++ b/src/app/hooks/useTheme.ts
@@ -1,20 +1,9 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
+import { isLocalImportThemeUrl } from '../theme/localImportUrls';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
-import {
- butterTheme,
- cinnyDarkTheme,
- darkTheme,
- lightTheme,
- rosePineTheme,
- silverTheme,
- cinnyLightTheme,
- cinnySilverTheme,
- gruvdarkTheme,
- accordTheme,
- blackTheme,
-} from '../../colors.css';
+import { darkTheme, lightTheme } from '../../colors.css';
export enum ThemeKind {
Light = 'light',
@@ -25,86 +14,50 @@ export type Theme = {
id: string;
kind: ThemeKind;
classNames: string[];
+ remoteFullUrl?: string;
};
+export const REMOTE_THEME_ID = 'sable-remote-theme';
+
+const isRemoteLoadedThemeUrl = (u: string | undefined): u is string => {
+ if (!u) return false;
+ const t = u.trim();
+ return /^https:\/\//i.test(t) || isLocalImportThemeUrl(t);
+};
+
+function parseRemoteKind(value: 'light' | 'dark' | undefined, fallback: ThemeKind): ThemeKind {
+ if (value === 'dark') return ThemeKind.Dark;
+ if (value === 'light') return ThemeKind.Light;
+ return fallback;
+}
+
+function makeRemoteTheme(url: string, kind: ThemeKind): Theme {
+ const fw = kind === ThemeKind.Dark ? onDarkFontWeight : onLightFontWeight;
+ const kindClass = kind === ThemeKind.Dark ? 'sable-remote-kind-dark' : 'sable-remote-kind-light';
+ const legacyCssTheme = kind === ThemeKind.Dark ? 'dark-theme' : 'light-theme';
+ const veTheme = kind === ThemeKind.Dark ? darkTheme : lightTheme;
+ return {
+ id: REMOTE_THEME_ID,
+ kind,
+ classNames: ['sable-remote-theme', kindClass, legacyCssTheme, veTheme, fw],
+ remoteFullUrl: url.trim(),
+ };
+}
+
export const LightTheme: Theme = {
id: 'light-theme',
kind: ThemeKind.Light,
classNames: ['light-theme', lightTheme, onLightFontWeight],
};
-export const SilverTheme: Theme = {
- id: 'silver-theme',
- kind: ThemeKind.Light,
- classNames: ['silver-theme', silverTheme, onLightFontWeight],
-};
-export const CinnyLightTheme: Theme = {
- id: 'cinny-light-theme',
- kind: ThemeKind.Light,
- classNames: ['cinny-light-theme', cinnyLightTheme, onLightFontWeight],
-};
-export const CinnySilverTheme: Theme = {
- id: 'cinny-silver-theme',
- kind: ThemeKind.Light,
- classNames: ['cinny-silver-theme', cinnySilverTheme, onLightFontWeight],
-};
export const DarkTheme: Theme = {
id: 'dark-theme',
kind: ThemeKind.Dark,
classNames: ['dark-theme', darkTheme, onDarkFontWeight],
};
-export const ButterTheme: Theme = {
- id: 'butter-theme',
- kind: ThemeKind.Dark,
- classNames: ['butter-theme', butterTheme, onDarkFontWeight],
-};
-export const RosePineTheme: Theme = {
- id: 'rose-pine-theme',
- kind: ThemeKind.Dark,
- classNames: ['rose-pine-theme', rosePineTheme, onDarkFontWeight],
-};
-
-export const GruvdarkTheme: Theme = {
- id: 'gruvdark-theme',
- kind: ThemeKind.Dark,
- classNames: ['gruvdark-theme', gruvdarkTheme, onDarkFontWeight],
-};
-
-export const CinnyDarkTheme: Theme = {
- id: 'cinny-dark-theme',
- kind: ThemeKind.Dark,
- classNames: ['cinny-dark-theme', cinnyDarkTheme, onDarkFontWeight],
-};
-
-export const AccordTheme: Theme = {
- id: 'accord-theme',
- kind: ThemeKind.Dark,
- classNames: ['accord-theme', accordTheme, onDarkFontWeight],
-};
-
-export const BlackTheme: Theme = {
- id: 'black-theme',
- kind: ThemeKind.Dark,
- classNames: ['black-theme', blackTheme, onDarkFontWeight],
-};
export const useThemes = (): Theme[] => {
- const themes: Theme[] = useMemo(
- () => [
- LightTheme,
- SilverTheme,
- CinnyLightTheme,
- CinnySilverTheme,
- DarkTheme,
- ButterTheme,
- RosePineTheme,
- CinnyDarkTheme,
- GruvdarkTheme,
- AccordTheme,
- BlackTheme,
- ],
- []
- );
+ const themes: Theme[] = useMemo(() => [LightTheme, DarkTheme], []);
return themes;
};
@@ -113,16 +66,7 @@ export const useThemeNames = (): Record =>
useMemo(
() => ({
[LightTheme.id]: 'Light',
- [SilverTheme.id]: 'Silver',
- [CinnyLightTheme.id]: 'Cinny Light',
- [CinnySilverTheme.id]: 'Cinny Silver',
[DarkTheme.id]: 'Dark',
- [ButterTheme.id]: 'Butter',
- [CinnyDarkTheme.id]: 'Cinny Dark',
- [RosePineTheme.id]: 'Rose Pine',
- [GruvdarkTheme.id]: 'GruvDark',
- [AccordTheme.id]: 'Accord',
- [BlackTheme.id]: 'Black',
}),
[]
);
@@ -154,19 +98,32 @@ export const useActiveTheme = (): Theme => {
const [themeId] = useSetting(settingsAtom, 'themeId');
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
+ const [manualRemoteUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl');
+ const [lightRemoteUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl');
+ const [darkRemoteUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl');
+ const [manualRemoteKind] = useSetting(settingsAtom, 'themeRemoteManualKind');
+ const [lightRemoteKind] = useSetting(settingsAtom, 'themeRemoteLightKind');
+ const [darkRemoteKind] = useSetting(settingsAtom, 'themeRemoteDarkKind');
if (!systemTheme) {
- const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
-
- return selectedTheme;
+ if (isRemoteLoadedThemeUrl(manualRemoteUrl)) {
+ const inferred = themeId === 'dark-theme' ? ThemeKind.Dark : ThemeKind.Light;
+ return makeRemoteTheme(manualRemoteUrl, parseRemoteKind(manualRemoteKind, inferred));
+ }
+ return themes.find((theme) => theme.id === themeId) ?? LightTheme;
}
- const selectedTheme =
- systemThemeKind === ThemeKind.Dark
- ? (themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme)
- : (themes.find((theme) => theme.id === lightThemeId) ?? LightTheme);
+ const isDark = systemThemeKind === ThemeKind.Dark;
+ const slotRemoteUrl = isDark ? darkRemoteUrl : lightRemoteUrl;
+ if (isRemoteLoadedThemeUrl(slotRemoteUrl)) {
+ const defaultSlotKind = isDark ? ThemeKind.Dark : ThemeKind.Light;
+ const slotKind = isDark ? darkRemoteKind : lightRemoteKind;
+ return makeRemoteTheme(slotRemoteUrl, parseRemoteKind(slotKind, defaultSlotKind));
+ }
- return selectedTheme;
+ return isDark
+ ? (themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme)
+ : (themes.find((theme) => theme.id === lightThemeId) ?? LightTheme);
};
const ThemeContext = createContext(null);
diff --git a/src/app/pages/ThemeManager.test.tsx b/src/app/pages/ThemeManager.test.tsx
index 81cc4e991..6bec44ad9 100644
--- a/src/app/pages/ThemeManager.test.tsx
+++ b/src/app/pages/ThemeManager.test.tsx
@@ -9,6 +9,7 @@ const settings = {
saturationLevel: 100,
underlineLinks: false,
reducedMotion: false,
+ themeRemoteEnabledTweakFullUrls: [] as string[],
};
let systemThemeKind = ThemeKind.Light;
@@ -68,6 +69,7 @@ beforeEach(() => {
settings.saturationLevel = 100;
settings.underlineLinks = false;
settings.reducedMotion = false;
+ settings.themeRemoteEnabledTweakFullUrls = [];
document.body.className = '';
document.body.style.filter = '';
});
diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx
index d47b73cbf..079e563a8 100644
--- a/src/app/pages/ThemeManager.tsx
+++ b/src/app/pages/ThemeManager.tsx
@@ -11,6 +11,32 @@ import {
import { ArboriumThemeBridge } from '$plugins/arborium';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
+import { getCachedThemeCss, putCachedThemeCss } from '../theme/cache';
+import { isLocalImportBundledUrl } from '../theme/localImportUrls';
+
+const REMOTE_STYLE_ID = 'sable-remote-theme-style';
+const REMOTE_TWEAKS_STYLE_ID = 'sable-remote-tweaks-style';
+
+async function loadRemoteThemeCssText(url: string): Promise {
+ try {
+ const cached = await getCachedThemeCss(url);
+ if (cached) return cached;
+ } catch {
+ /* IndexedDB unavailable */
+ }
+ if (isLocalImportBundledUrl(url)) {
+ return undefined;
+ }
+ const res = await fetch(url, { mode: 'cors' });
+ if (!res.ok) return undefined;
+ const text = await res.text();
+ try {
+ await putCachedThemeCss(url, text);
+ } catch {
+ /* cache optional */
+ }
+ return text;
+}
export function UnAuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind();
@@ -34,6 +60,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const [saturation] = useSetting(settingsAtom, 'saturationLevel');
const [underlineLinks] = useSetting(settingsAtom, 'underlineLinks');
const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion');
+ const [enabledTweakUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls');
useEffect(() => {
document.body.className = '';
@@ -61,6 +88,62 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
}
}, [activeTheme, saturation, underlineLinks, reducedMotion]);
+ useEffect(() => {
+ const url = activeTheme.remoteFullUrl?.trim();
+ let cancelled = false;
+
+ if (url) {
+ (async () => {
+ const text = await loadRemoteThemeCssText(url);
+ if (cancelled) return;
+ if (!text) {
+ document.getElementById(REMOTE_STYLE_ID)?.remove();
+ return;
+ }
+ let node = document.getElementById(REMOTE_STYLE_ID) as HTMLStyleElement | null;
+ if (!node) {
+ node = document.createElement('style');
+ node.id = REMOTE_STYLE_ID;
+ document.head.appendChild(node);
+ }
+ node.textContent = text;
+ })();
+ } else {
+ document.getElementById(REMOTE_STYLE_ID)?.remove();
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [activeTheme.remoteFullUrl]);
+
+ useEffect(() => {
+ const urls = (enabledTweakUrls ?? []).filter((u) => u.trim().length > 0);
+ let cancelled = false;
+
+ if (urls.length === 0) {
+ document.getElementById(REMOTE_TWEAKS_STYLE_ID)?.remove();
+ return undefined;
+ }
+
+ (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));
+ let node = document.getElementById(REMOTE_TWEAKS_STYLE_ID) as HTMLStyleElement | null;
+ if (!node) {
+ node = document.createElement('style');
+ node.id = REMOTE_TWEAKS_STYLE_ID;
+ document.head.appendChild(node);
+ }
+ node.textContent = chunks.join('\n\n');
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [enabledTweakUrls]);
+
return (
{children}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 26ac2f431..cb6430e06 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -51,6 +51,7 @@ import { createDebugLogger } from '$utils/debugLogger';
import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
import { getSlidingSyncManager } from '$client/initMatrix';
import { NotificationBanner } from '$components/notification-banner';
+import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner';
import { TelemetryConsentBanner } from '$components/telemetry-consent';
import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
@@ -861,6 +862,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 3dcf1b1fb..b243cd8ea 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -23,6 +23,23 @@ export enum CaptionPosition {
}
export type JumboEmojiSize = 'none' | 'extraSmall' | 'small' | 'normal' | 'large' | 'extraLarge';
+export type ThemeRemoteFavorite = {
+ fullUrl: string;
+ displayName: string;
+ basename: string;
+ kind: 'light' | 'dark';
+ pinned?: boolean;
+ importedLocal?: boolean;
+};
+
+export type ThemeRemoteTweakFavorite = {
+ fullUrl: string;
+ displayName: string;
+ basename: string;
+ pinned?: boolean;
+ importedLocal?: boolean;
+};
+
export interface Settings {
themeId?: string;
useSystemTheme: boolean;
@@ -119,9 +136,25 @@ export interface Settings {
// furry stuff
renderAnimals: boolean;
+
+ // theme catalog
+ themeCatalogOnboardingDone: boolean;
+ themeRemoteFavorites: ThemeRemoteFavorite[];
+ themeRemoteCatalogEnabled: boolean;
+ themeChatPreviewAnyUrl: boolean;
+ themeChatPreviewApprovedCatalogOnly: boolean;
+ themeRemoteManualFullUrl?: string;
+ themeRemoteLightFullUrl?: string;
+ themeRemoteDarkFullUrl?: string;
+ themeRemoteManualKind?: 'light' | 'dark';
+ themeRemoteLightKind?: 'light' | 'dark';
+ themeRemoteDarkKind?: 'light' | 'dark';
+ themeMigrationDismissed: boolean;
+ themeRemoteTweakFavorites: ThemeRemoteTweakFavorite[];
+ themeRemoteEnabledTweakFullUrls: string[];
}
-const defaultSettings: Settings = {
+export const defaultSettings: Settings = {
themeId: undefined,
useSystemTheme: true,
lightThemeId: undefined,
@@ -218,6 +251,22 @@ const defaultSettings: Settings = {
// furry stuff
renderAnimals: true,
+
+ // theme catalog
+ themeCatalogOnboardingDone: false,
+ themeRemoteFavorites: [],
+ themeRemoteCatalogEnabled: false,
+ themeChatPreviewAnyUrl: false,
+ themeChatPreviewApprovedCatalogOnly: false,
+ themeRemoteManualFullUrl: undefined,
+ themeRemoteLightFullUrl: undefined,
+ themeRemoteDarkFullUrl: undefined,
+ themeRemoteManualKind: undefined,
+ themeRemoteLightKind: undefined,
+ themeRemoteDarkKind: undefined,
+ themeMigrationDismissed: false,
+ themeRemoteTweakFavorites: [],
+ themeRemoteEnabledTweakFullUrls: [],
};
export const getSettings = () => {
@@ -234,10 +283,7 @@ export const getSettings = () => {
}
delete parsed.monochromeMode;
- return {
- ...defaultSettings,
- ...(parsed as Settings),
- };
+ return parsed;
};
export const setSettings = (settings: Settings) => {
diff --git a/src/app/styles/themes.css b/src/app/styles/themes.css
index 116eb0949..d936dcd96 100755
--- a/src/app/styles/themes.css
+++ b/src/app/styles/themes.css
@@ -87,274 +87,6 @@
--sable-overlay: rgba(0, 0, 0, 0.4);
}
-/* --- Silver --- */
-:root,
-.silver-theme {
- /* Background */
- --sable-bg-container: #f7f6f9;
- --sable-bg-container-hover: #edecf2;
- --sable-bg-container-active: #e3e2eb;
- --sable-bg-container-line: #d9d8e4;
- --sable-bg-on-container: #2d235c;
-
- /* Surface */
- --sable-surface-container: #ffffff;
- --sable-surface-container-hover: #f7f6f9;
- --sable-surface-container-active: #edecf2;
- --sable-surface-container-line: #e3e2eb;
- --sable-surface-on-container: #2d235c;
-
- /* Surface Variant */
- --sable-surface-var-container: #f0eff4;
- --sable-surface-var-container-hover: #e6e5ed;
- --sable-surface-var-container-active: #dcdae6;
- --sable-surface-var-container-line: #d2d1df;
- --sable-surface-var-on-container: #514861;
-
- /* Primary */
- --sable-primary-main: #6e56cf;
- --sable-primary-main-hover: #644ec1;
- --sable-primary-main-active: #5b47b3;
- --sable-primary-main-line: #5240a5;
- --sable-primary-on-main: #ffffff;
- --sable-primary-container: #ded9e6;
- --sable-primary-container-hover: #d3cde0;
- --sable-primary-container-active: #c8c1d9;
- --sable-primary-container-line: #bdb6d3;
- --sable-primary-on-container: #2d235c;
-
- /* Secondary */
- --sable-sec-main: #514861;
- --sable-sec-main-hover: #453e54;
- --sable-sec-main-active: #3b3447;
- --sable-sec-main-line: #312e3b;
- --sable-sec-on-main: #ffffff;
- --sable-sec-container: #eae8f0;
- --sable-sec-container-hover: #dedce8;
- --sable-sec-container-active: #d2d0e0;
- --sable-sec-container-line: #c6c4d8;
- --sable-sec-on-container: #2d235c;
-
- /* Success */
- --sable-success-main: #017343;
- --sable-success-main-hover: #01683c;
- --sable-success-main-active: #016239;
- --sable-success-main-line: #015c36;
- --sable-success-on-main: #ffffff;
- --sable-success-container: #bfdcd0;
- --sable-success-container-hover: #b3d5c7;
- --sable-success-container-active: #a6cebd;
- --sable-success-container-line: #99c7b4;
- --sable-success-on-container: #01512f;
-
- /* Warning */
- --sable-warn-main: #864300;
- --sable-warn-main-hover: #793c00;
- --sable-warn-main-active: #723900;
- --sable-warn-main-line: #6b3600;
- --sable-warn-on-main: #ffffff;
- --sable-warn-container: #e1d0bf;
- --sable-warn-container-hover: #dbc7b2;
- --sable-warn-container-active: #d5bda6;
- --sable-warn-container-line: #cfb499;
- --sable-warn-on-container: #5e2f00;
-
- /* Critical */
- --sable-crit-main: #9d0f0f;
- --sable-crit-main-hover: #8d0e0e;
- --sable-crit-main-active: #850d0d;
- --sable-crit-main-line: #7e0c0c;
- --sable-crit-on-main: #ffffff;
- --sable-crit-container: #e7c3c3;
- --sable-crit-container-hover: #e2b7b7;
- --sable-crit-container-active: #ddabab;
- --sable-crit-container-line: #d89f9f;
- --sable-crit-on-container: #6e0b0b;
-
- /* Other */
- --sable-focus-ring: rgba(110, 86, 207, 0.4);
- --sable-shadow: rgba(45, 35, 92, 0.1);
- --sable-overlay: rgba(45, 35, 92, 0.4);
-}
-
-/* --- Cinny Light --- */
-.cinny-light-theme {
- /* Background */
- --sable-bg-container: #f2f2f2;
- --sable-bg-container-hover: #e5e5e5;
- --sable-bg-container-active: #d9d9d9;
- --sable-bg-container-line: #cccccc;
- --sable-bg-on-container: #000000;
-
- /* Surface */
- --sable-surface-container: #ffffff;
- --sable-surface-container-hover: #f2f2f2;
- --sable-surface-container-active: #e5e5e5;
- --sable-surface-container-line: #d9d9d9;
- --sable-surface-on-container: #000000;
-
- /* Surface Variant */
- --sable-surface-var-container: #f2f2f2;
- --sable-surface-var-container-hover: #e5e5e5;
- --sable-surface-var-container-active: #d9d9d9;
- --sable-surface-var-container-line: #cccccc;
- --sable-surface-var-on-container: #000000;
-
- /* Primary */
- --sable-primary-main: #1858d5;
- --sable-primary-main-hover: #164fc0;
- --sable-primary-main-active: #144bb5;
- --sable-primary-main-line: #1346aa;
- --sable-primary-on-main: #ffffff;
- --sable-primary-container: #e8eefb;
- --sable-primary-container-hover: #dce6f9;
- --sable-primary-container-active: #d1def7;
- --sable-primary-container-line: #c5d5f5;
- --sable-primary-on-container: #113e95;
-
- /* Secondary */
- --sable-sec-main: #000000;
- --sable-sec-main-hover: #1a1a1a;
- --sable-sec-main-active: #262626;
- --sable-sec-main-line: #333333;
- --sable-sec-on-main: #ffffff;
- --sable-sec-container: #d9d9d9;
- --sable-sec-container-hover: #cccccc;
- --sable-sec-container-active: #bfbfbf;
- --sable-sec-container-line: #b2b2b2;
- --sable-sec-on-container: #0d0d0d;
-
- /* Success */
- --sable-success-main: #00844c;
- --sable-success-main-hover: #007744;
- --sable-success-main-active: #007041;
- --sable-success-main-line: #006a3d;
- --sable-success-on-main: #ffffff;
- --sable-success-container: #e5f3ed;
- --sable-success-container-hover: #d9ede4;
- --sable-success-container-active: #cce6db;
- --sable-success-container-line: #bfe0d2;
- --sable-success-on-container: #005c35;
-
- /* Warning */
- --sable-warn-main: #a85400;
- --sable-warn-main-hover: #974c00;
- --sable-warn-main-active: #8f4700;
- --sable-warn-main-line: #864300;
- --sable-warn-on-main: #ffffff;
- --sable-warn-container: #f6eee5;
- --sable-warn-container-hover: #f2e5d9;
- --sable-warn-container-active: #eeddcc;
- --sable-warn-container-line: #e9d4bf;
- --sable-warn-on-container: #763b00;
-
- /* Critical */
- --sable-crit-main: #c40e0e;
- --sable-crit-main-hover: #ac0909;
- --sable-crit-main-active: #a60c0c;
- --sable-crit-main-line: #9c0b0b;
- --sable-crit-on-main: #ffffff;
- --sable-crit-container: #f9e7e7;
- --sable-crit-container-hover: #f6dbdb;
- --sable-crit-container-active: #f3cfcf;
- --sable-crit-container-line: #f0c3c3;
- --sable-crit-on-container: #890a0a;
-
- /* Other */
- --sable-focus-ring: rgba(0 0 0 / 50%);
- --sable-shadow: rgba(0 0 0 / 20%);
- --sable-overlay: rgba(0 0 0 / 50%);
-}
-
-/* --- Cinny Silver --- */
-.cinny-silver-theme {
- /* Background */
- --sable-bg-container: #dedede;
- --sable-bg-container-hover: #d3d3d3;
- --sable-bg-container-active: #c7c7c7;
- --sable-bg-container-line: #bbbbbb;
- --sable-bg-on-container: #000000;
-
- /* Surface */
- --sable-surface-container: #eaeaea;
- --sable-surface-container-hover: #dedede;
- --sable-surface-container-active: #d3d3d3;
- --sable-surface-container-line: #c7c7c7;
- --sable-surface-on-container: #000000;
-
- /* Surface Variant */
- --sable-surface-var-container: #dedede;
- --sable-surface-var-container-hover: #d3d3d3;
- --sable-surface-var-container-active: #c7c7c7;
- --sable-surface-var-container-line: #bbbbbb;
- --sable-surface-var-on-container: #000000;
-
- /* Primary */
- --sable-primary-main: #1245a8;
- --sable-primary-main-hover: #103e97;
- --sable-primary-main-active: #0f3b8f;
- --sable-primary-main-line: #0e3786;
- --sable-primary-on-main: #ffffff;
- --sable-primary-container: #c4d0e9;
- --sable-primary-container-hover: #b8c7e5;
- --sable-primary-container-active: #acbee1;
- --sable-primary-container-line: #a0b5dc;
- --sable-primary-on-container: #0d3076;
-
- /* Secondary */
- --sable-sec-main: #000000;
- --sable-sec-main-hover: #171717;
- --sable-sec-main-active: #232323;
- --sable-sec-main-line: #2f2f2f;
- --sable-sec-on-main: #eaeaea;
- --sable-sec-container: #c7c7c7;
- --sable-sec-container-hover: #bbbbbb;
- --sable-sec-container-active: #afafaf;
- --sable-sec-container-line: #a4a4a4;
- --sable-sec-on-container: #0c0c0c;
-
- /* Success */
- --sable-success-main: #017343;
- --sable-success-main-hover: #01683c;
- --sable-success-main-active: #016239;
- --sable-success-main-line: #015c36;
- --sable-success-on-main: #ffffff;
- --sable-success-container: #bfdcd0;
- --sable-success-container-hover: #b3d5c7;
- --sable-success-container-active: #a6cebd;
- --sable-success-container-line: #99c7b4;
- --sable-success-on-container: #01512f;
-
- /* Warning */
- --sable-warn-main: #864300;
- --sable-warn-main-hover: #793c00;
- --sable-warn-main-active: #723900;
- --sable-warn-main-line: #6b3600;
- --sable-warn-on-main: #ffffff;
- --sable-warn-container: #e1d0bf;
- --sable-warn-container-hover: #dbc7b2;
- --sable-warn-container-active: #d5bda6;
- --sable-warn-container-line: #cfb499;
- --sable-warn-on-container: #5e2f00;
-
- /* Critical */
- --sable-crit-main: #9d0f0f;
- --sable-crit-main-hover: #8d0e0e;
- --sable-crit-main-active: #850d0d;
- --sable-crit-main-line: #7e0c0c;
- --sable-crit-on-main: #ffffff;
- --sable-crit-container: #e7c3c3;
- --sable-crit-container-hover: #e2b7b7;
- --sable-crit-container-active: #ddabab;
- --sable-crit-container-line: #d89f9f;
- --sable-crit-on-container: #6e0b0b;
-
- /* Other */
- --sable-focus-ring: rgba(0 0 0 / 50%);
- --sable-shadow: rgba(0 0 0 / 20%);
- --sable-overlay: rgba(0 0 0 / 50%);
-}
-
/* --- Dark --- */
.dark-theme {
/* Background */
@@ -443,536 +175,3 @@
--sable-shadow: rgba(0, 0, 0, 0.4);
--sable-overlay: rgba(15, 14, 18, 0.85);
}
-
-/* --- Butter --- */
-.butter-theme {
- /* Background */
- --sable-bg-container: #1a1916;
- --sable-bg-container-hover: #26241f;
- --sable-bg-container-active: #333029;
- --sable-bg-container-line: #403c33;
- --sable-bg-on-container: #fffbde;
-
- /* Surface */
- --sable-surface-container: #26241f;
- --sable-surface-container-hover: #333029;
- --sable-surface-container-active: #403c33;
- --sable-surface-container-line: #4d483d;
- --sable-surface-on-container: #fffbde;
-
- /* Surface Variant */
- --sable-surface-var-container: #12110f;
- --sable-surface-var-container-hover: #1b1a17;
- --sable-surface-var-container-active: #24221f;
- --sable-surface-var-container-line: #403c33;
- --sable-surface-var-on-container: #e5e2c8;
-
- /* Primary */
- --sable-primary-main: #e3ba91;
- --sable-primary-main-hover: #dfaf7e;
- --sable-primary-main-active: #dda975;
- --sable-primary-main-line: #daa36c;
- --sable-primary-on-main: #1a1916;
- --sable-primary-container: #453324;
- --sable-primary-container-hover: #563f2d;
- --sable-primary-container-active: #674b36;
- --sable-primary-container-line: #78573f;
- --sable-primary-on-container: #fffbde;
-
- /* Secondary */
- --sable-sec-main: #fffbde;
- --sable-sec-main-hover: #e5e2c8;
- --sable-sec-main-active: #d9d5bd;
- --sable-sec-main-line: #ccc9b2;
- --sable-sec-on-main: #1a1916;
- --sable-sec-container: #333029;
- --sable-sec-container-hover: #403c33;
- --sable-sec-container-active: #4d483d;
- --sable-sec-container-line: #595447;
- --sable-sec-on-container: #fffbde;
-
- /* Success */
- --sable-success-main: #85e0ba;
- --sable-success-main-hover: #70dbaf;
- --sable-success-main-active: #66d9a9;
- --sable-success-main-line: #5cd6a3;
- --sable-success-on-main: #0f3d2a;
- --sable-success-container: #175c3f;
- --sable-success-container-hover: #1a6646;
- --sable-success-container-active: #1c704d;
- --sable-success-container-line: #1f7a54;
- --sable-success-on-container: #ccf2e2;
-
- /* Warning */
- --sable-warn-main: #e3ba91;
- --sable-warn-main-hover: #dfaf7e;
- --sable-warn-main-active: #dda975;
- --sable-warn-main-line: #daa36c;
- --sable-warn-on-main: #3f2a15;
- --sable-warn-container: #5e3f20;
- --sable-warn-container-hover: #694624;
- --sable-warn-container-active: #734d27;
- --sable-warn-container-line: #7d542b;
- --sable-warn-on-container: #f3e2d1;
-
- /* Critical */
- --sable-crit-main: #e69d9d;
- --sable-crit-main-hover: #e28d8d;
- --sable-crit-main-active: #e08585;
- --sable-crit-main-line: #de7d7d;
- --sable-crit-on-main: #401c1c;
- --sable-crit-container: #602929;
- --sable-crit-container-hover: #6b2e2e;
- --sable-crit-container-active: #763333;
- --sable-crit-container-line: #803737;
- --sable-crit-on-container: #f5d6d6;
-
- /* Other */
- --sable-focus-ring: rgba(227, 186, 145, 0.5);
- --sable-shadow: rgba(0, 0, 0, 0.6);
- --sable-overlay: rgba(15, 14, 12, 0.9);
-}
-
-/* --- Rose Pine --- */
-.rose-pine-theme {
- /* Background */
- --sable-bg-container: #191724;
- --sable-bg-container-hover: #1f1d2e;
- --sable-bg-container-active: #26233a;
- --sable-bg-container-line: #2a2837;
- --sable-bg-on-container: #e0def4;
-
- /* Surface */
- --sable-surface-container: #1f1d2e;
- --sable-surface-container-hover: #26233a;
- --sable-surface-container-active: #2a2837;
- --sable-surface-container-line: #393552;
- --sable-surface-on-container: #e0def4;
-
- /* Surface Variant */
- --sable-surface-var-container: #131020;
- --sable-surface-var-container-hover: #191724;
- --sable-surface-var-container-active: #1f1d2e;
- --sable-surface-var-container-line: #393552;
- --sable-surface-var-on-container: #c4a7e7;
-
- /* Primary */
- --sable-primary-main: #c4a7e7;
- --sable-primary-main-hover: #b695db;
- --sable-primary-main-active: #a783cf;
- --sable-primary-main-line: #9871c3;
- --sable-primary-on-main: #191724;
- --sable-primary-container: #2a273f;
- --sable-primary-container-hover: #363350;
- --sable-primary-container-active: #423f61;
- --sable-primary-container-line: #4e4b72;
- --sable-primary-on-container: #e0def4;
-
- /* Secondary */
- --sable-sec-main: #908caa;
- --sable-sec-main-hover: #a09db8;
- --sable-sec-main-active: #b0aec6;
- --sable-sec-main-line: #c0bed4;
- --sable-sec-on-main: #191724;
- --sable-sec-container: #26233a;
- --sable-sec-container-hover: #2a2837;
- --sable-sec-container-active: #393552;
- --sable-sec-container-line: #44415a;
- --sable-sec-on-container: #e0def4;
-
- /* Success */
- --sable-success-main: #9ccfd8;
- --sable-success-main-hover: #87c5cf;
- --sable-success-main-active: #7abcc6;
- --sable-success-main-line: #6db2bd;
- --sable-success-on-main: #1a2b2e;
- --sable-success-container: #274248;
- --sable-success-container-hover: #2e4d54;
- --sable-success-container-active: #355860;
- --sable-success-container-line: #3c636c;
- --sable-success-on-container: #d8f0f2;
-
- /* Warning */
- --sable-warn-main: #f6c177;
- --sable-warn-main-hover: #f4b562;
- --sable-warn-main-active: #f2ad57;
- --sable-warn-main-line: #f0a54c;
- --sable-warn-on-main: #3d2f1a;
- --sable-warn-container: #5d4728;
- --sable-warn-container-hover: #6b522f;
- --sable-warn-container-active: #795d36;
- --sable-warn-container-line: #87683d;
- --sable-warn-on-container: #fcedd6;
-
- /* Critical */
- --sable-crit-main: #eb6f92;
- --sable-crit-main-hover: #e85d82;
- --sable-crit-main-active: #e65479;
- --sable-crit-main-line: #e44b70;
- --sable-crit-on-main: #3d1d27;
- --sable-crit-container: #5c2c3a;
- --sable-crit-container-hover: #693343;
- --sable-crit-container-active: #763a4c;
- --sable-crit-container-line: #834155;
- --sable-crit-on-container: #f9d8e1;
-
- /* Other */
- --sable-focus-ring: rgba(196, 167, 231, 0.5);
- --sable-shadow: rgba(0, 0, 0, 0.5);
- --sable-overlay: rgba(25, 23, 36, 0.85);
-}
-
-/* --- Cinny Dark --- */
-.cinny-dark-theme {
- /* Background */
- --sable-bg-container: #1a1a1a;
- --sable-bg-container-hover: #262626;
- --sable-bg-container-active: #333333;
- --sable-bg-container-line: #404040;
- --sable-bg-on-container: #f2f2f2;
-
- /* Surface */
- --sable-surface-container: #262626;
- --sable-surface-container-hover: #333333;
- --sable-surface-container-active: #404040;
- --sable-surface-container-line: #4d4d4d;
- --sable-surface-on-container: #f2f2f2;
-
- /* Surface Variant */
- --sable-surface-var-container: #333333;
- --sable-surface-var-container-hover: #404040;
- --sable-surface-var-container-active: #4d4d4d;
- --sable-surface-var-container-line: #595959;
- --sable-surface-var-on-container: #f2f2f2;
-
- /* Primary */
- --sable-primary-main: #bdb6ec;
- --sable-primary-main-hover: #b2aae9;
- --sable-primary-main-active: #ada3e8;
- --sable-primary-main-line: #a79de6;
- --sable-primary-on-main: #2c2843;
- --sable-primary-container: #413c65;
- --sable-primary-container-hover: #494370;
- --sable-primary-container-active: #50497b;
- --sable-primary-container-line: #575086;
- --sable-primary-on-container: #e3e1f7;
-
- /* Secondary */
- --sable-sec-main: #ffffff;
- --sable-sec-main-hover: #e5e5e5;
- --sable-sec-main-active: #d9d9d9;
- --sable-sec-main-line: #cccccc;
- --sable-sec-on-main: #1a1a1a;
- --sable-sec-container: #404040;
- --sable-sec-container-hover: #4d4d4d;
- --sable-sec-container-active: #595959;
- --sable-sec-container-line: #666666;
- --sable-sec-on-container: #f2f2f2;
-
- /* Success */
- --sable-success-main: #85e0ba;
- --sable-success-main-hover: #70dbaf;
- --sable-success-main-active: #66d9a9;
- --sable-success-main-line: #5cd6a3;
- --sable-success-on-main: #0f3d2a;
- --sable-success-container: #175c3f;
- --sable-success-container-hover: #1a6646;
- --sable-success-container-active: #1c704d;
- --sable-success-container-line: #1f7a54;
- --sable-success-on-container: #ccf2e2;
-
- /* Warning */
- --sable-warn-main: #e3ba91;
- --sable-warn-main-hover: #dfaf7e;
- --sable-warn-main-active: #dda975;
- --sable-warn-main-line: #daa36c;
- --sable-warn-on-main: #3f2a15;
- --sable-warn-container: #5e3f20;
- --sable-warn-container-hover: #694624;
- --sable-warn-container-active: #734d27;
- --sable-warn-container-line: #7d542b;
- --sable-warn-on-container: #f3e2d1;
-
- /* Critical */
- --sable-crit-main: #e69d9d;
- --sable-crit-main-hover: #e28d8d;
- --sable-crit-main-active: #e08585;
- --sable-crit-main-line: #de7d7d;
- --sable-crit-on-main: #401c1c;
- --sable-crit-container: #602929;
- --sable-crit-container-hover: #6b2e2e;
- --sable-crit-container-active: #763333;
- --sable-crit-container-line: #803737;
- --sable-crit-on-container: #f5d6d6;
-
- /* Other */
- --sable-focus-ring: rgba(255, 255, 255, 0.5);
- --sable-shadow: rgba(0, 0, 0, 1);
- --sable-overlay: rgba(0, 0, 0, 0.8);
-}
-
-/* --- Gruvdark --- */
-.gruvdark-theme {
- /* Background */
- --sable-bg-container: #282828;
- --sable-bg-container-hover: #3c3836;
- --sable-bg-container-active: #504945;
- --sable-bg-container-line: #665c54;
- --sable-bg-on-container: #fbf1c7;
-
- /* Surface */
- --sable-surface-container: #3c3836;
- --sable-surface-container-hover: #32302f;
- --sable-surface-container-active: #504945;
- --sable-surface-container-line: #665c54;
- --sable-surface-on-container: #fbf1c7;
-
- /* Surface Variant */
- --sable-surface-var-container: #282828;
- --sable-surface-var-container-hover: #1d2021;
- --sable-surface-var-container-active: #3c3836;
- --sable-surface-var-container-line: #a89984;
- --sable-surface-var-on-container: #ebdbb2;
-
- /* Primary */
- --sable-primary-main: #fbf1c7;
- --sable-primary-main-hover: #ebdbb2;
- --sable-primary-main-active: #d5c4a1;
- --sable-primary-main-line: #bdae93;
- --sable-primary-on-main: #1d2021;
- --sable-primary-container: #fe8019;
- --sable-primary-container-hover: #d65d0e;
- --sable-primary-container-active: #e67111;
- --sable-primary-container-line: #fbf1c7;
- --sable-primary-on-container: #fbf1c7;
-
- /* Secondary */
- --sable-sec-main: #fbf1c7;
- --sable-sec-main-hover: #ebdbb2;
- --sable-sec-main-active: #d5c4a1;
- --sable-sec-main-line: #bdae93;
- --sable-sec-on-main: #1d2021;
- --sable-sec-container: #282828;
- --sable-sec-container-hover: #3c3836;
- --sable-sec-container-active: #4d483d;
- --sable-sec-container-line: #a89984;
- --sable-sec-on-container: #fbf1c7;
-
- /* Success */
- --sable-success-main: #6e981a;
- --sable-success-main-hover: #6e981a;
- --sable-success-main-active: #6e981a;
- --sable-success-main-line: #6e981a;
- --sable-success-on-main: #c2c455;
- --sable-success-container: #c2c455;
- --sable-success-container-hover: #c2c455;
- --sable-success-container-active: #c2c455;
- --sable-success-container-line: #c2c455;
- --sable-success-on-container: #fbf1c7;
-
- /* Warning */
- --sable-warn-main: #fe8019;
- --sable-warn-main-hover: #fe8019;
- --sable-warn-main-active: #fe8019;
- --sable-warn-main-line: #fe8019;
- --sable-warn-on-main: #d65d0e;
- --sable-warn-container: #d65d0e;
- --sable-warn-container-hover: #d65d0e;
- --sable-warn-container-active: #d65d0e;
- --sable-warn-container-line: #d65d0e;
- --sable-warn-on-container: #fbf1c7;
-
- /* Critical */
- --sable-crit-main: #fb4834;
- --sable-crit-main-hover: #fb4834;
- --sable-crit-main-active: #fb4834;
- --sable-crit-main-line: #fb4834;
- --sable-crit-on-main: #cc241d;
- --sable-crit-container: #4c1a18;
- --sable-crit-container-hover: #4c1a18;
- --sable-crit-container-active: #763333;
- --sable-crit-container-line: #803737;
- --sable-crit-on-container: #fbf1c7;
-
- /* Other */
- --sable-focus-ring: rgba(227, 186, 145, 0.5);
- --sable-shadow: rgba(0, 0, 0, 0.6);
- --sable-overlay: rgba(15, 14, 12, 0.9);
-}
-
-/* --- Accord Theme --- */
-.accord-theme {
- /* Background */
- --sable-bg-container: #2c2d32;
- --sable-bg-container-hover: #35353a;
- --sable-bg-container-active: #414248;
- --sable-bg-container-line: #38393e;
- --sable-bg-on-container: #f2f2f2;
-
- /* Surface */
- --sable-surface-container: #323339;
- --sable-surface-container-hover: #3a3b41;
- --sable-surface-container-active: #404040;
- --sable-surface-container-line: #484a50;
- --sable-surface-on-container: #fff;
-
- /* Surface Variant */
- --sable-surface-var-container: #393a41;
- --sable-surface-var-container-hover: #404040;
- --sable-surface-var-container-active: #4d4d4d;
- --sable-surface-var-container-line: #3d3e44;
- --sable-surface-var-on-container: #f2f2f2;
-
- /* Primary */
- --sable-primary-main: #5865f2;
- --sable-primary-main-hover: #b2aae9;
- --sable-primary-main-active: #ada3e8;
- --sable-primary-main-line: #a79de6;
- --sable-primary-on-main: #fff;
- --sable-primary-container: #413c65;
- --sable-primary-container-hover: #494370;
- --sable-primary-container-active: #50497b;
- --sable-primary-container-line: #575086;
- --sable-primary-on-container: #e3e1f7;
-
- /* Secondary */
- --sable-sec-main: #ffffff;
- --sable-sec-main-hover: #e5e5e5;
- --sable-sec-main-active: #d9d9d9;
- --sable-sec-main-line: #cccccc;
- --sable-sec-on-main: #1a1a1a;
- --sable-sec-container: #323339;
- --sable-sec-container-hover: #4d4d4d;
- --sable-sec-container-active: #595959;
- --sable-sec-container-line: #46474e;
- --sable-sec-on-container: #f2f2f2;
-
- /* Success */
- --sable-success-main: #85e0ba;
- --sable-success-main-hover: #70dbaf;
- --sable-success-main-active: #66d9a9;
- --sable-success-main-line: #5cd6a3;
- --sable-success-on-main: #0f3d2a;
- --sable-success-container: #175c3f;
- --sable-success-container-hover: #1a6646;
- --sable-success-container-active: #1c704d;
- --sable-success-container-line: #1f7a54;
- --sable-success-on-container: #ccf2e2;
-
- /* Warning */
- --sable-warn-main: #e3ba91;
- --sable-warn-main-hover: #dfaf7e;
- --sable-warn-main-active: #dda975;
- --sable-warn-main-line: #daa36c;
- --sable-warn-on-main: #3f2a15;
- --sable-warn-container: #5e3f20;
- --sable-warn-container-hover: #694624;
- --sable-warn-container-active: #734d27;
- --sable-warn-container-line: #7d542b;
- --sable-warn-on-container: #f3e2d1;
-
- /* Crtical */
- --sable-crit-main: #f7908b;
- --sable-crit-main-hover: #e28d8d;
- --sable-crit-main-active: #e08585;
- --sable-crit-main-line: #de7d7d;
- --sable-crit-on-main: #401c1c;
- --sable-crit-container: #602929;
- --sable-crit-container-hover: #6b2e2e;
- --sable-crit-container-active: #763333;
- --sable-crit-container-line: #803737;
- --sable-crit-on-container: #f5d6d6;
-
- /* Other */
- --sable-focus-ring: rgba(255, 255, 255, 0.5);
- --sable-shadow: rgba(0, 0, 0, 1);
- --sable-overlay: rgba(0, 0, 0, 0.8);
-}
-
-.black-theme {
- /* Background */
- --sable-bg-container: #000000;
- --sable-bg-container-hover: #101010;
- --sable-bg-container-active: #101010;
- --sable-bg-container-line: #702070;
- --sable-bg-on-container: #ffffff;
-
- /* Surface */
- --sable-surface-container: #000000;
- --sable-surface-container-hover: #101010;
- --sable-surface-container-active: #101010;
- --sable-surface-container-line: #702070;
- --sable-surface-on-container: #ffffff;
-
- /* Surface Variant */
- --sable-surface-var-container: #050505;
- --sable-surface-var-container-hover: #101010;
- --sable-surface-var-container-active: #101010;
- --sable-surface-var-container-line: #702070;
- --sable-surface-var-on-container: #ffffff;
-
- /* Primary */
- --sable-primary-main: #bdb6ec;
- --sable-primary-main-hover: #a9a1e6;
- --sable-primary-main-active: #958be0;
- --sable-primary-main-line: #8175da;
- --sable-primary-on-main: #1b1a21;
- --sable-primary-container: #2d235c;
- --sable-primary-container-hover: #382d70;
- --sable-primary-container-active: #433784;
- --sable-primary-container-line: #4e4198;
- --sable-primary-on-container: #e3e1f7;
-
- /* Secondary */
- --sable-sec-main: #9992ac;
- --sable-sec-main-hover: #aaa4ba;
- --sable-sec-main-active: #bbb6c8;
- --sable-sec-main-line: #ccc8d6;
- --sable-sec-on-main: #000000;
- --sable-sec-container: #101010;
- --sable-sec-container-hover: #202020;
- --sable-sec-container-active: #202020;
- --sable-sec-container-line: #404040;
- --sable-sec-on-container: #eae8f0;
-
- /* Success */
- --sable-success-main: #85e0ba;
- --sable-success-main-hover: #70dbaf;
- --sable-success-main-active: #66d9a9;
- --sable-success-main-line: #5cd6a3;
- --sable-success-on-main: #0f3d2a;
- --sable-success-container: #175c3f;
- --sable-success-container-hover: #1a6646;
- --sable-success-container-active: #1c704d;
- --sable-success-container-line: #1f7a54;
- --sable-success-on-container: #ccf2e2;
-
- /* Warning */
- --sable-warn-main: #e3ba91;
- --sable-warn-main-hover: #dfaf7e;
- --sable-warn-main-active: #dda975;
- --sable-warn-main-line: #daa36c;
- --sable-warn-on-main: #3f2a15;
- --sable-warn-container: #5e3f20;
- --sable-warn-container-hover: #694624;
- --sable-warn-container-active: #734d27;
- --sable-warn-container-line: #7d542b;
- --sable-warn-on-container: #f3e2d1;
-
- /* Critical */
- --sable-crit-main: #e69d9d;
- --sable-crit-main-hover: #e28d8d;
- --sable-crit-main-active: #e08585;
- --sable-crit-main-line: #de7d7d;
- --sable-crit-on-main: #401c1c;
- --sable-crit-container: #602929;
- --sable-crit-container-hover: #6b2e2e;
- --sable-crit-container-active: #763333;
- --sable-crit-container-line: #803737;
- --sable-crit-on-container: #f5d6d6;
-
- /* Other */
- --sable-focus-ring: rgba(189, 182, 236, 0.5);
- --sable-shadow: rgba(0, 0, 0, 0.4);
- --sable-overlay: rgba(15, 14, 18, 0.85);
-}
diff --git a/src/app/theme/cache.ts b/src/app/theme/cache.ts
new file mode 100644
index 000000000..7517e9c4d
--- /dev/null
+++ b/src/app/theme/cache.ts
@@ -0,0 +1,54 @@
+const DB_NAME = 'sable-theme-cache';
+const DB_VERSION = 1;
+const STORE = 'themes';
+
+export type CachedThemeEntry = {
+ url: string;
+ cssText: string;
+ cachedAt: number;
+};
+
+function openDb(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+ req.onerror = () => reject(req.error);
+ req.onsuccess = () => resolve(req.result);
+ req.onupgradeneeded = () => {
+ req.result.createObjectStore(STORE, { keyPath: 'url' });
+ };
+ });
+}
+
+export async function getCachedThemeCss(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.onerror = () => reject(req.error);
+ req.onsuccess = () => {
+ const row = req.result as CachedThemeEntry | undefined;
+ resolve(row?.cssText);
+ };
+ });
+}
+
+export async function putCachedThemeCss(url: string, cssText: string): 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);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+export async function clearThemeCache(): Promise {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).clear();
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
diff --git a/src/app/theme/catalog.test.ts b/src/app/theme/catalog.test.ts
new file mode 100644
index 000000000..791892972
--- /dev/null
+++ b/src/app/theme/catalog.test.ts
@@ -0,0 +1,233 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ fetchThemeCatalogBundle,
+ listThemePairsFromCatalog,
+ themeCatalogManifestUrlFromBase,
+} from './catalog';
+
+describe('themeCatalogManifestUrlFromBase', () => {
+ it('resolves catalog.json beside raw root', () => {
+ expect(
+ themeCatalogManifestUrlFromBase('https://raw.githubusercontent.com/SableClient/themes/main/')
+ ).toBe('https://raw.githubusercontent.com/SableClient/themes/main/catalog.json');
+ expect(
+ themeCatalogManifestUrlFromBase('https://raw.githubusercontent.com/SableClient/themes/main')
+ ).toBe('https://raw.githubusercontent.com/SableClient/themes/main/catalog.json');
+ });
+
+ it('resolves under nested path', () => {
+ expect(
+ themeCatalogManifestUrlFromBase(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/dist/themes/'
+ )
+ ).toBe('https://raw.githubusercontent.com/SableClient/themes/main/dist/themes/catalog.json');
+ });
+});
+
+describe('listThemePairsFromCatalog', () => {
+ const origFetch = globalThis.fetch;
+
+ afterEach(() => {
+ globalThis.fetch = origFetch;
+ });
+
+ it('loads pairs from catalog.json manifest when present', async () => {
+ const base = 'https://raw.githubusercontent.com/SableClient/themes/main/';
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ version: 1,
+ themes: [
+ {
+ basename: 'rose-pine',
+ previewUrl:
+ 'https://raw.githubusercontent.com/SableClient/themes/main/themes/rose-pine.preview.sable.css',
+ fullUrl:
+ 'https://raw.githubusercontent.com/SableClient/themes/main/themes/rose-pine.sable.css',
+ },
+ ],
+ }),
+ });
+
+ const pairs = await listThemePairsFromCatalog(base);
+
+ expect(pairs).toHaveLength(1);
+ expect(pairs[0].basename).toBe('rose-pine');
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/catalog.json',
+ expect.objectContaining({ mode: 'cors' })
+ );
+ });
+
+ it('uses custom manifest URL when provided', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ themes: [
+ {
+ basename: 'x',
+ previewUrl: 'https://cdn.example/p.preview.sable.css',
+ fullUrl: 'https://cdn.example/p.sable.css',
+ },
+ ],
+ }),
+ });
+
+ const pairs = await listThemePairsFromCatalog(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/',
+ {
+ manifestUrl: 'https://pages.example.com/catalog.json',
+ }
+ );
+
+ expect(pairs[0].basename).toBe('x');
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://pages.example.com/catalog.json',
+ expect.anything()
+ );
+ });
+
+ it('returns empty array when manifest is valid but themes is empty', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ version: 1, themes: [] }),
+ });
+
+ const pairs = await listThemePairsFromCatalog(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/'
+ );
+ expect(pairs).toEqual([]);
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to GitHub contents API when manifest fetch fails', async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 404 })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => [
+ {
+ name: 'rose-pine.preview.sable.css',
+ path: 'themes/rose-pine.preview.sable.css',
+ type: 'file',
+ download_url:
+ 'https://raw.githubusercontent.com/SableClient/themes/main/themes/rose-pine.preview.sable.css',
+ },
+ {
+ name: 'rose-pine.sable.css',
+ path: 'themes/rose-pine.sable.css',
+ type: 'file',
+ download_url:
+ 'https://raw.githubusercontent.com/SableClient/themes/main/themes/rose-pine.sable.css',
+ },
+ ],
+ });
+
+ const base = 'https://raw.githubusercontent.com/SableClient/themes/main/';
+ const pairs = await listThemePairsFromCatalog(base);
+
+ expect(pairs).toHaveLength(1);
+ expect(pairs[0].basename).toBe('rose-pine');
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/catalog.json',
+ expect.objectContaining({ mode: 'cors' })
+ );
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://api.github.com/repos/SableClient/themes/contents/themes?ref=main',
+ expect.objectContaining({ headers: { Accept: 'application/vnd.github+json' } })
+ );
+ });
+
+ it('requests nested directory when catalog URL includes a path and manifest is missing', async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 404 })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ });
+
+ await listThemePairsFromCatalog(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/dist/themes/'
+ );
+
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://raw.githubusercontent.com/SableClient/themes/main/dist/themes/catalog.json',
+ expect.anything()
+ );
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://api.github.com/repos/SableClient/themes/contents/dist/themes/themes?ref=main',
+ expect.anything()
+ );
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://api.github.com/repos/SableClient/themes/contents/dist/themes/tweaks?ref=main',
+ expect.anything()
+ );
+ });
+
+ it('returns empty array when base URL is not a raw GitHub URL and manifest fails', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ const pairs = await listThemePairsFromCatalog('https://example.com/');
+ expect(pairs).toEqual([]);
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ 'https://example.com/catalog.json',
+ expect.anything()
+ );
+ });
+
+ it('loads tweaks from manifest when themes array is empty', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ version: 1,
+ themes: [],
+ tweaks: [
+ {
+ basename: 'rounded',
+ fullUrl:
+ 'https://raw.githubusercontent.com/SableClient/themes/main/tweaks/rounded.sable.css',
+ },
+ ],
+ }),
+ });
+ const base = 'https://raw.githubusercontent.com/SableClient/themes/main/';
+ const bundle = await fetchThemeCatalogBundle(base);
+ expect(bundle.themes).toEqual([]);
+ expect(bundle.tweaks).toHaveLength(1);
+ expect(bundle.tweaks[0].basename).toBe('rounded');
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('loads tweaks from GitHub tweaks/ when manifest is missing', async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 404 })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => [
+ {
+ name: 'rounded.sable.css',
+ path: 'tweaks/rounded.sable.css',
+ type: 'file',
+ download_url:
+ 'https://raw.githubusercontent.com/SableClient/themes/main/tweaks/rounded.sable.css',
+ },
+ ],
+ });
+ const base = 'https://raw.githubusercontent.com/SableClient/themes/main/';
+ const bundle = await fetchThemeCatalogBundle(base);
+ expect(bundle.themes).toEqual([]);
+ expect(bundle.tweaks).toHaveLength(1);
+ expect(bundle.tweaks[0].basename).toBe('rounded');
+ });
+});
diff --git a/src/app/theme/catalog.ts b/src/app/theme/catalog.ts
new file mode 100644
index 000000000..05bc74376
--- /dev/null
+++ b/src/app/theme/catalog.ts
@@ -0,0 +1,198 @@
+import { parseGithubRawBaseUrl, rawFileUrl, type GithubRawParts } from './githubRaw';
+
+export type GithubContentItem = {
+ name: string;
+ path: string;
+ type: 'file' | 'dir';
+ download_url: string | null;
+};
+
+export type ThemePair = {
+ basename: string;
+ previewUrl: string;
+ fullUrl: string;
+};
+
+export type TweakCatalogEntry = {
+ basename: string;
+ fullUrl: string;
+};
+
+export type ThemeCatalogBundle = {
+ themes: ThemePair[];
+ tweaks: TweakCatalogEntry[];
+};
+
+export type ThemeCatalogManifest = {
+ version?: number;
+ themes?: ThemePair[];
+ tweaks?: TweakCatalogEntry[];
+};
+
+export type ListThemeCatalogOptions = {
+ manifestUrl?: string | null;
+};
+
+const PREVIEW_SUFFIX = '.preview.sable.css';
+const FULL_SUFFIX = '.sable.css';
+
+export function themeCatalogManifestUrlFromBase(catalogBaseUrl: string): string | null {
+ const trimmed = catalogBaseUrl.trim();
+ if (!trimmed) return null;
+ const base = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
+ try {
+ return new URL('catalog.json', base).href;
+ } catch {
+ return null;
+ }
+}
+
+function parseThemePairRows(themes: unknown[]): ThemePair[] {
+ return themes
+ .map((row) => {
+ if (!row || typeof row !== 'object') return null;
+ const { basename, previewUrl, fullUrl } = row as Record;
+ if (
+ typeof basename !== 'string' ||
+ typeof previewUrl !== 'string' ||
+ typeof fullUrl !== 'string'
+ ) {
+ return null;
+ }
+ if (!basename || !previewUrl || !fullUrl) return null;
+ return { basename, previewUrl, fullUrl };
+ })
+ .filter((pair): pair is ThemePair => pair !== null);
+}
+
+function parseTweakRows(tweaks: unknown[]): TweakCatalogEntry[] {
+ return tweaks
+ .map((row) => {
+ if (!row || typeof row !== 'object') return null;
+ const { basename, fullUrl } = row as Record;
+ if (typeof basename !== 'string' || typeof fullUrl !== 'string') return null;
+ if (!basename || !fullUrl) return null;
+ return { basename, fullUrl };
+ })
+ .filter((e): e is TweakCatalogEntry => e !== null);
+}
+
+async function fetchCatalogBundleFromManifest(
+ manifestUrl: string
+): Promise {
+ const res = await fetch(manifestUrl, { mode: 'cors' });
+ if (!res.ok) return null;
+ let data: unknown;
+ try {
+ data = await res.json();
+ } catch {
+ return null;
+ }
+ if (!data || typeof data !== 'object') return null;
+ const { themes, tweaks } = data as Record;
+ if (!Array.isArray(themes)) return null;
+ const parsedThemes = parseThemePairRows(themes);
+ const parsedTweaks = Array.isArray(tweaks) ? parseTweakRows(tweaks) : [];
+ return { themes: parsedThemes, tweaks: parsedTweaks };
+}
+
+/** Path segments for GET /repos/.../contents/{path} */
+function directoryPathToApiSegment(directoryPath: string): string {
+ if (!directoryPath) return '';
+ return directoryPath
+ .split('/')
+ .filter(Boolean)
+ .map((seg) => encodeURIComponent(seg))
+ .join('/');
+}
+
+async function fetchGithubContents(parts: GithubRawParts): Promise {
+ const encoded = directoryPathToApiSegment(parts.directoryPath);
+ const pathSeg = encoded ? `/${encoded}` : '';
+ const apiUrl = `https://api.github.com/repos/${parts.owner}/${parts.repo}/contents${pathSeg}?ref=${encodeURIComponent(parts.ref)}`;
+ const res = await fetch(apiUrl, {
+ headers: { Accept: 'application/vnd.github+json' },
+ });
+ if (!res.ok) {
+ throw new Error(`theme catalog list failed: ${res.status}`);
+ }
+ const data = (await res.json()) as GithubContentItem | GithubContentItem[];
+ return Array.isArray(data) ? data : [data];
+}
+
+async function listTweakEntriesFromGithub(parts: GithubRawParts): Promise {
+ const tweakDir = parts.directoryPath ? `${parts.directoryPath}/tweaks` : 'tweaks';
+ let items: GithubContentItem[];
+ try {
+ items = await fetchGithubContents({ ...parts, directoryPath: tweakDir });
+ } catch {
+ return [];
+ }
+ return items
+ .filter((i) => i.type === 'file' && i.name.endsWith(FULL_SUFFIX))
+ .map((i) => {
+ const basename = i.name.slice(0, -FULL_SUFFIX.length);
+ return {
+ basename,
+ fullUrl: i.download_url ?? rawFileUrl({ ...parts, directoryPath: tweakDir }, i.name),
+ };
+ })
+ .filter((e) => Boolean(e.fullUrl));
+}
+
+async function listThemePairsFromGithub(parts: GithubRawParts): Promise {
+ const themeDir = parts.directoryPath ? `${parts.directoryPath}/themes` : 'themes';
+ const dirParts: GithubRawParts = { ...parts, directoryPath: themeDir };
+ const items = await fetchGithubContents(dirParts);
+ const previewFiles = items.filter((i) => i.type === 'file' && i.name.endsWith(PREVIEW_SUFFIX));
+ return previewFiles
+ .map((p) => {
+ const basename = p.name.slice(0, -PREVIEW_SUFFIX.length);
+ const fullName = `${basename}${FULL_SUFFIX}`;
+ const full = items.find((i) => i.type === 'file' && i.name === fullName);
+ if (!full) return null;
+ const previewUrl = p.download_url ?? rawFileUrl(dirParts, p.name);
+ const fullUrl = full.download_url ?? rawFileUrl(dirParts, fullName);
+ if (!previewUrl || !fullUrl) return null;
+ return {
+ basename,
+ previewUrl,
+ fullUrl,
+ };
+ })
+ .filter((pair): pair is ThemePair => pair !== null);
+}
+
+export async function fetchThemeCatalogBundle(
+ baseUrl: string,
+ options?: ListThemeCatalogOptions
+): Promise {
+ const manifestUrl =
+ options?.manifestUrl?.trim() || themeCatalogManifestUrlFromBase(baseUrl) || undefined;
+ if (manifestUrl) {
+ const fromManifest = await fetchCatalogBundleFromManifest(manifestUrl);
+ if (fromManifest !== null) return fromManifest;
+ }
+
+ const parts = parseGithubRawBaseUrl(baseUrl);
+ if (!parts) return { themes: [], tweaks: [] };
+ const themes = await listThemePairsFromGithub(parts);
+ const tweaks = await listTweakEntriesFromGithub(parts);
+ return { themes, tweaks };
+}
+
+export async function listThemePairsFromCatalog(
+ baseUrl: string,
+ options?: ListThemeCatalogOptions
+): Promise {
+ const bundle = await fetchThemeCatalogBundle(baseUrl, options);
+ return bundle.themes;
+}
+
+export async function listTweakEntriesFromCatalog(
+ baseUrl: string,
+ options?: ListThemeCatalogOptions
+): Promise {
+ const bundle = await fetchThemeCatalogBundle(baseUrl, options);
+ return bundle.tweaks;
+}
diff --git a/src/app/theme/catalogDefaults.test.ts b/src/app/theme/catalogDefaults.test.ts
new file mode 100644
index 000000000..6ba6b2b85
--- /dev/null
+++ b/src/app/theme/catalogDefaults.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+
+import { DEFAULT_THEME_CATALOG_BASE, themeCatalogListingBaseUrl } from './catalogDefaults';
+
+describe('catalogDefaults', () => {
+ it('themeCatalogListingBaseUrl uses default without trailing slash in constant', () => {
+ expect(DEFAULT_THEME_CATALOG_BASE.endsWith('/')).toBe(false);
+ expect(themeCatalogListingBaseUrl()).toBe(`${DEFAULT_THEME_CATALOG_BASE}/`);
+ });
+
+ it('themeCatalogListingBaseUrl normalizes configured base', () => {
+ expect(themeCatalogListingBaseUrl('https://example.com/themes')).toBe(
+ 'https://example.com/themes/'
+ );
+ expect(themeCatalogListingBaseUrl('https://example.com/themes/')).toBe(
+ 'https://example.com/themes/'
+ );
+ });
+});
diff --git a/src/app/theme/catalogDefaults.ts b/src/app/theme/catalogDefaults.ts
new file mode 100644
index 000000000..281054c38
--- /dev/null
+++ b/src/app/theme/catalogDefaults.ts
@@ -0,0 +1,13 @@
+import { trimTrailingSlash } from '$utils/common';
+
+export const DEFAULT_THEME_CATALOG_BASE =
+ 'https://raw.githubusercontent.com/SableClient/themes/main';
+
+export function themeCatalogListingBaseUrl(configBaseTrimmed?: string | null): string {
+ const base = trimTrailingSlash(
+ configBaseTrimmed && configBaseTrimmed.length > 0
+ ? configBaseTrimmed
+ : DEFAULT_THEME_CATALOG_BASE
+ );
+ return `${base}/`;
+}
diff --git a/src/app/theme/githubRaw.ts b/src/app/theme/githubRaw.ts
new file mode 100644
index 000000000..224b67ff4
--- /dev/null
+++ b/src/app/theme/githubRaw.ts
@@ -0,0 +1,26 @@
+/**
+ * Parse a raw.githubusercontent.com base URL into API parameters.
+ * E.g. https://raw.githubusercontent.com/SableClient/themes/main/themes/
+ */
+export type GithubRawParts = {
+ owner: string;
+ repo: string;
+ ref: string;
+ directoryPath: string;
+};
+
+const RAW_RE = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)(?:\/(.*))?$/;
+
+export function parseGithubRawBaseUrl(baseUrl: string): GithubRawParts | null {
+ const trimmed = baseUrl.trim().replace(/\/+$/, '');
+ const m = trimmed.match(RAW_RE);
+ if (!m) return null;
+ const [, owner, repo, ref, rest] = m;
+ const directoryPath = (rest ?? '').replace(/^\/+|\/+$/g, '');
+ return { owner, repo, ref, directoryPath };
+}
+
+export function rawFileUrl(parts: GithubRawParts, fileName: string): string {
+ const dir = parts.directoryPath ? `${parts.directoryPath}/` : '';
+ return `https://raw.githubusercontent.com/${parts.owner}/${parts.repo}/${parts.ref}/${dir}${fileName}`;
+}
diff --git a/src/app/theme/legacyToCatalogMap.test.ts b/src/app/theme/legacyToCatalogMap.test.ts
new file mode 100644
index 000000000..f350f3fb4
--- /dev/null
+++ b/src/app/theme/legacyToCatalogMap.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+
+import { defaultSettings } from '$state/settings';
+
+import {
+ catalogFullUrlForBasename,
+ inferLegacyKindFromBasename,
+ isLegacyThemeId,
+ legacyThemeIdToBasename,
+ needsLegacyThemeMigration,
+} from './legacyToCatalogMap';
+
+describe('legacyToCatalogMap', () => {
+ it('treats only light-theme and dark-theme as non-legacy', () => {
+ expect(isLegacyThemeId(undefined)).toBe(false);
+ expect(isLegacyThemeId('light-theme')).toBe(false);
+ expect(isLegacyThemeId('dark-theme')).toBe(false);
+ expect(isLegacyThemeId('silver-theme')).toBe(true);
+ });
+
+ it('maps legacy id to basename', () => {
+ expect(legacyThemeIdToBasename('silver-theme')).toBe('silver');
+ expect(legacyThemeIdToBasename('cinny-light-theme')).toBe('cinny-light');
+ });
+
+ it('builds catalog full URL under themes/', () => {
+ expect(catalogFullUrlForBasename('silver', 'https://example.com/catalog-root')).toBe(
+ 'https://example.com/catalog-root/themes/silver.sable.css'
+ );
+ });
+
+ it('infers kind from basename when metadata missing', () => {
+ expect(inferLegacyKindFromBasename('silver')).toBe('light');
+ expect(inferLegacyKindFromBasename('black')).toBe('dark');
+ });
+
+ it('needs migration when dismissed is false and a slot is legacy', () => {
+ expect(
+ needsLegacyThemeMigration({
+ ...defaultSettings,
+ themeMigrationDismissed: false,
+ themeId: 'silver-theme',
+ })
+ ).toBe(true);
+ expect(
+ needsLegacyThemeMigration({
+ ...defaultSettings,
+ themeMigrationDismissed: true,
+ themeId: 'silver-theme',
+ })
+ ).toBe(false);
+ });
+});
diff --git a/src/app/theme/legacyToCatalogMap.ts b/src/app/theme/legacyToCatalogMap.ts
new file mode 100644
index 000000000..d5bd1726f
--- /dev/null
+++ b/src/app/theme/legacyToCatalogMap.ts
@@ -0,0 +1,44 @@
+import { REMOTE_THEME_ID } from '$hooks/useTheme';
+import type { Settings } from '$state/settings';
+import { trimTrailingSlash } from '$utils/common';
+
+export const BUILTIN_THEME_IDS = new Set(['light-theme', 'dark-theme']);
+
+export function isLegacyThemeId(themeId: string | undefined): boolean {
+ if (!themeId || themeId.trim() === '') return false;
+ if (BUILTIN_THEME_IDS.has(themeId)) return false;
+ if (themeId === REMOTE_THEME_ID) return false;
+ return true;
+}
+
+export function legacyThemeIdToBasename(legacyId: string): string {
+ return legacyId.endsWith('-theme') ? legacyId.slice(0, -'-theme'.length) : legacyId;
+}
+
+const DARK_BASENAMES = new Set([
+ 'dark',
+ 'black',
+ 'cinny-dark',
+ 'gruvdark',
+ 'accord',
+ 'butter',
+ 'rose-pine',
+]);
+
+export function inferLegacyKindFromBasename(basename: string): 'light' | 'dark' {
+ return DARK_BASENAMES.has(basename) ? 'dark' : 'light';
+}
+
+export function catalogFullUrlForBasename(basename: string, catalogBase: string): string {
+ const base = trimTrailingSlash(catalogBase);
+ return `${base}/themes/${basename}.sable.css`;
+}
+
+export function needsLegacyThemeMigration(settings: Settings): boolean {
+ if (settings.themeMigrationDismissed) return false;
+ return (
+ isLegacyThemeId(settings.themeId) ||
+ isLegacyThemeId(settings.lightThemeId) ||
+ isLegacyThemeId(settings.darkThemeId)
+ );
+}
diff --git a/src/app/theme/localImportUrls.ts b/src/app/theme/localImportUrls.ts
new file mode 100644
index 000000000..7ec38a24e
--- /dev/null
+++ b/src/app/theme/localImportUrls.ts
@@ -0,0 +1,34 @@
+export const SABLE_LOCAL_THEME_PREFIX = 'sable-import://theme/';
+export const SABLE_LOCAL_TWEAK_PREFIX = 'sable-import://tweak/';
+
+export function isLocalImportThemeUrl(url: string): boolean {
+ return url.startsWith(SABLE_LOCAL_THEME_PREFIX);
+}
+
+export function isLocalImportTweakUrl(url: string): boolean {
+ return url.startsWith(SABLE_LOCAL_TWEAK_PREFIX);
+}
+
+export function isLocalImportBundledUrl(url: string): boolean {
+ return isLocalImportThemeUrl(url) || isLocalImportTweakUrl(url);
+}
+
+export function makeLocalImportThemeId(): string {
+ return crypto.randomUUID();
+}
+
+export function makeLocalImportTweakId(): string {
+ return crypto.randomUUID();
+}
+
+export function localImportFullUrl(id: string): string {
+ return `${SABLE_LOCAL_THEME_PREFIX}${id}/full.sable.css`;
+}
+
+export function localImportPreviewUrl(id: string): string {
+ return `${SABLE_LOCAL_THEME_PREFIX}${id}/preview.sable.css`;
+}
+
+export function localImportTweakFullUrl(id: string): string {
+ return `${SABLE_LOCAL_TWEAK_PREFIX}${id}/full.sable.css`;
+}
diff --git a/src/app/theme/metadata.test.ts b/src/app/theme/metadata.test.ts
new file mode 100644
index 000000000..c0c68b0b3
--- /dev/null
+++ b/src/app/theme/metadata.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+import { ThemeKind } from '$hooks/useTheme';
+
+import {
+ getSableCssPackageKind,
+ parseSableThemeMetadata,
+ parseSableTweakMetadata,
+} from './metadata';
+
+describe('parseSableThemeMetadata', () => {
+ it('reads @sable-theme from a block after an earlier license comment', () => {
+ const css = `/* MIT license
+ * blah
+ */
+/*
+@sable-theme
+---
+id: foo
+name: Bar Theme
+kind: light
+*/
+:root {}
+`;
+ const meta = parseSableThemeMetadata(css);
+ expect(meta.id).toBe('foo');
+ expect(meta.name).toBe('Bar Theme');
+ expect(meta.kind).toBe(ThemeKind.Light);
+ });
+
+ it('returns empty when only a non-metadata comment exists', () => {
+ const css = `/* just a license */`;
+ expect(parseSableThemeMetadata(css)).toEqual({});
+ });
+});
+
+describe('getSableCssPackageKind', () => {
+ it('detects tweak before theme when tweak block appears first', () => {
+ expect(
+ getSableCssPackageKind(`/*
+@sable-tweak
+id: x
+*/
+`)
+ ).toBe('tweak');
+ });
+
+ it('detects theme when only @sable-theme is present', () => {
+ expect(
+ getSableCssPackageKind(`/*
+@sable-theme
+id: dark
+*/
+`)
+ ).toBe('theme');
+ });
+
+ it('returns unknown when no markers', () => {
+ expect(getSableCssPackageKind('/* license only */')).toBe('unknown');
+ });
+});
+
+describe('parseSableTweakMetadata', () => {
+ it('reads description from @sable-tweak block', () => {
+ const css = `/*
+@sable-tweak
+id: rounded
+name: Softer corners
+description: Adjusts shadow depth.
+author: Sable
+tags: demo, layout
+*/
+body.sable-remote-theme {}
+`;
+ const meta = parseSableTweakMetadata(css);
+ expect(meta.id).toBe('rounded');
+ expect(meta.name).toBe('Softer corners');
+ expect(meta.description).toBe('Adjusts shadow depth.');
+ expect(meta.tags).toEqual(['demo', 'layout']);
+ });
+});
diff --git a/src/app/theme/metadata.ts b/src/app/theme/metadata.ts
new file mode 100644
index 000000000..c49f68629
--- /dev/null
+++ b/src/app/theme/metadata.ts
@@ -0,0 +1,155 @@
+import { ThemeKind } from '$hooks/useTheme';
+
+export type SableThemeContrast = 'low' | 'high';
+
+export type SableThemeMetadata = {
+ id: string;
+ name: string;
+ author?: string;
+ kind: ThemeKind;
+ contrast: SableThemeContrast;
+ tags: string[];
+ fullThemeUrl?: string;
+};
+
+export type SableTweakMetadata = {
+ id: string;
+ name: string;
+ description?: string;
+ author?: string;
+ tags: string[];
+};
+
+const META_THEME = '@sable-theme';
+const META_TWEAK = '@sable-tweak';
+
+export function getSableCssPackageKind(cssText: string): 'theme' | 'tweak' | 'unknown' {
+ let pos = 0;
+ while (pos < cssText.length) {
+ const start = cssText.indexOf('/*', pos);
+ if (start === -1) break;
+ const end = cssText.indexOf('*/', start + 2);
+ if (end === -1) break;
+ const block = cssText.slice(start + 2, end);
+ if (block.includes(META_TWEAK)) return 'tweak';
+ if (block.includes(META_THEME)) return 'theme';
+ pos = end + 2;
+ }
+ return 'unknown';
+}
+
+function extractBlockCommentContaining(cssText: string, marker: string): string {
+ let pos = 0;
+ while (pos < cssText.length) {
+ const start = cssText.indexOf('/*', pos);
+ if (start === -1) return '';
+ const end = cssText.indexOf('*/', start + 2);
+ if (end === -1) return '';
+ const block = cssText.slice(start + 2, end);
+ if (block.includes(marker)) {
+ return block;
+ }
+ pos = end + 2;
+ }
+ return '';
+}
+
+function extractSableThemeBlockComment(cssText: string): string {
+ return extractBlockCommentContaining(cssText, META_THEME);
+}
+
+export function parseSableThemeMetadata(cssText: string): Partial {
+ const block = extractSableThemeBlockComment(cssText);
+ if (!block) return {};
+
+ const lines = block.split(/\r?\n/).map((l) => l.replace(/^\s*\*?\s?/, '').trim());
+ const out: Partial = {};
+ lines
+ .filter((line) => !(line.startsWith('@') || line === '---' || line === ''))
+ .filter((line) => line.indexOf(':') !== -1)
+ .forEach((line) => {
+ const idx = line.indexOf(':');
+ const key = line.slice(0, idx).trim().toLowerCase();
+ const value = line.slice(idx + 1).trim();
+ switch (key) {
+ case 'id':
+ out.id = value;
+ break;
+ case 'name':
+ out.name = value;
+ break;
+ case 'author':
+ out.author = value;
+ break;
+ case 'kind':
+ out.kind = value === 'dark' ? ThemeKind.Dark : ThemeKind.Light;
+ break;
+ case 'contrast':
+ out.contrast = value === 'high' ? 'high' : 'low';
+ break;
+ case 'tags':
+ out.tags = value
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean);
+ break;
+ case 'fullthemeurl':
+ case 'full_theme_url':
+ out.fullThemeUrl = value;
+ break;
+ default:
+ break;
+ }
+ });
+ return out;
+}
+
+export function parseSableTweakMetadata(cssText: string): Partial {
+ const block = extractBlockCommentContaining(cssText, META_TWEAK);
+ if (!block) return {};
+
+ const lines = block.split(/\r?\n/).map((l) => l.replace(/^\s*\*?\s?/, '').trim());
+ const out: Partial = {};
+ lines
+ .filter((line) => !(line.startsWith('@') || line === '---' || line === ''))
+ .filter((line) => line.indexOf(':') !== -1)
+ .forEach((line) => {
+ const idx = line.indexOf(':');
+ const key = line.slice(0, idx).trim().toLowerCase();
+ const value = line.slice(idx + 1).trim();
+ switch (key) {
+ case 'id':
+ out.id = value;
+ break;
+ case 'name':
+ out.name = value;
+ break;
+ case 'description':
+ out.description = value;
+ break;
+ case 'author':
+ out.author = value;
+ break;
+ case 'tags':
+ out.tags = value
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean);
+ break;
+ default:
+ break;
+ }
+ });
+ return out;
+}
+
+export function extractFullThemeUrlFromPreview(cssText: string): string | undefined {
+ const meta = parseSableThemeMetadata(cssText);
+ if (meta.fullThemeUrl && /^https:\/\//i.test(meta.fullThemeUrl)) {
+ return meta.fullThemeUrl;
+ }
+ const block = extractSableThemeBlockComment(cssText);
+ if (!block) return undefined;
+ const m = block.match(/fullThemeUrl:\s*(https:\/\/[^\s*]+)/i);
+ return m?.[1];
+}
diff --git a/src/app/theme/migrateLegacyThemes.ts b/src/app/theme/migrateLegacyThemes.ts
new file mode 100644
index 000000000..9c51afdd2
--- /dev/null
+++ b/src/app/theme/migrateLegacyThemes.ts
@@ -0,0 +1,152 @@
+import { ThemeKind } from '$hooks/useTheme';
+import type { Settings, ThemeRemoteFavorite } from '$state/settings';
+import { trimTrailingSlash } from '$utils/common';
+
+import { putCachedThemeCss } from './cache';
+import { DEFAULT_THEME_CATALOG_BASE } from './catalogDefaults';
+import {
+ catalogFullUrlForBasename,
+ inferLegacyKindFromBasename,
+ isLegacyThemeId,
+ legacyThemeIdToBasename,
+} from './legacyToCatalogMap';
+import { parseSableThemeMetadata } from './metadata';
+
+type FetchOk = {
+ kind: 'light' | 'dark';
+ displayName: string;
+ basename: string;
+};
+
+async function fetchAndCacheTheme(
+ url: string
+): Promise<{ ok: true; data: FetchOk } | { ok: false; error: string }> {
+ try {
+ const res = await fetch(url, { mode: 'cors' });
+ if (!res.ok) return { ok: false, error: `Could not download theme (${res.status}).` };
+ const text = await res.text();
+ await putCachedThemeCss(url, text);
+ const meta = parseSableThemeMetadata(text);
+ const basenameFromUrl =
+ url
+ .replace(/\.sable\.css$/i, '')
+ .split('/')
+ .pop() ?? 'theme';
+ const basename = meta.id?.trim() || basenameFromUrl;
+ let kind: 'light' | 'dark';
+ if (meta.kind === ThemeKind.Dark) kind = 'dark';
+ else if (meta.kind === ThemeKind.Light) kind = 'light';
+ else kind = inferLegacyKindFromBasename(basename);
+ const displayName = meta.name?.trim() || basename;
+ return { ok: true, data: { kind, displayName, basename } };
+ } catch {
+ return { ok: false, error: 'Network error while downloading theme.' };
+ }
+}
+
+function normalizeCatalogBase(catalogBase: string): string {
+ return trimTrailingSlash(catalogBase.length > 0 ? catalogBase : DEFAULT_THEME_CATALOG_BASE);
+}
+
+type Assign = 'manual' | 'light' | 'dark';
+
+export async function runLegacyThemeMigration(
+ settings: Settings,
+ catalogBase: string
+): Promise<{ ok: true; partial: Partial } | { ok: false; error: string }> {
+ const base = normalizeCatalogBase(catalogBase);
+
+ const partial: Partial = {
+ themeMigrationDismissed: true,
+ themeRemoteCatalogEnabled: true,
+ themeCatalogOnboardingDone: true,
+ };
+
+ const tasks: { legacyId: string; url: string; assign: Assign }[] = [];
+
+ if (!settings.useSystemTheme && isLegacyThemeId(settings.themeId)) {
+ const id = settings.themeId!;
+ tasks.push({
+ legacyId: id,
+ url: catalogFullUrlForBasename(legacyThemeIdToBasename(id), base),
+ assign: 'manual',
+ });
+ }
+
+ if (isLegacyThemeId(settings.lightThemeId)) {
+ const id = settings.lightThemeId!;
+ tasks.push({
+ legacyId: id,
+ url: catalogFullUrlForBasename(legacyThemeIdToBasename(id), base),
+ assign: 'light',
+ });
+ }
+
+ if (isLegacyThemeId(settings.darkThemeId)) {
+ const id = settings.darkThemeId!;
+ tasks.push({
+ legacyId: id,
+ url: catalogFullUrlForBasename(legacyThemeIdToBasename(id), base),
+ assign: 'dark',
+ });
+ }
+
+ if (tasks.length === 0) {
+ if (settings.useSystemTheme && isLegacyThemeId(settings.themeId)) {
+ partial.themeId = 'light-theme';
+ }
+ return { ok: true, partial };
+ }
+
+ const urlToFetched = new Map();
+ const uniqueUrls = [...new Set(tasks.map((t) => t.url))];
+
+ const fetchResults = await Promise.all(uniqueUrls.map((url) => fetchAndCacheTheme(url)));
+ const failed = fetchResults.find((r): r is { ok: false; error: string } => !r.ok);
+ if (failed) return failed;
+
+ uniqueUrls.forEach((url, i) => {
+ const r = fetchResults[i];
+ if (r.ok) urlToFetched.set(url, r.data);
+ });
+
+ tasks.forEach((task) => {
+ const data = urlToFetched.get(task.url)!;
+
+ if (task.assign === 'manual') {
+ partial.themeRemoteManualFullUrl = task.url;
+ partial.themeRemoteManualKind = data.kind;
+ partial.themeId = data.kind === 'dark' ? 'dark-theme' : 'light-theme';
+ } else if (task.assign === 'light') {
+ partial.themeRemoteLightFullUrl = task.url;
+ partial.themeRemoteLightKind = data.kind;
+ partial.lightThemeId = 'light-theme';
+ } else {
+ partial.themeRemoteDarkFullUrl = task.url;
+ partial.themeRemoteDarkKind = data.kind;
+ partial.darkThemeId = 'dark-theme';
+ }
+ });
+
+ if (settings.useSystemTheme && isLegacyThemeId(settings.themeId)) {
+ partial.themeId = 'light-theme';
+ }
+
+ const existing: ThemeRemoteFavorite[] = [...(settings.themeRemoteFavorites ?? [])];
+ const byUrl = new Map(existing.map((f) => [f.fullUrl, f]));
+ tasks.forEach((task) => {
+ const data = urlToFetched.get(task.url)!;
+ if (!byUrl.has(task.url)) {
+ byUrl.set(task.url, {
+ fullUrl: task.url,
+ displayName: data.displayName,
+ basename: data.basename,
+ kind: data.kind,
+ pinned: true,
+ });
+ }
+ });
+ partial.themeRemoteFavorites = [...byUrl.values()];
+
+ return { ok: true, partial };
+}
diff --git a/src/app/theme/previewCss.test.ts b/src/app/theme/previewCss.test.ts
new file mode 100644
index 000000000..53aef5402
--- /dev/null
+++ b/src/app/theme/previewCss.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ extractSafePreviewCustomProperties,
+ PREVIEW_CARD_SAFE_CUSTOM_PROPERTIES,
+} from './previewCss';
+
+describe('extractSafePreviewCustomProperties', () => {
+ it('keeps only allowlisted custom properties', () => {
+ const css = `
+ --sable-primary-main: #abc;
+ --sable-bg-container: #111;
+ --sable-extra-unused: red;
+ `;
+ const vars = extractSafePreviewCustomProperties(css);
+ expect(vars['--sable-primary-main']).toBe('#abc');
+ expect(vars['--sable-bg-container']).toBe('#111');
+ expect(vars['--sable-extra-unused']).toBeUndefined();
+ });
+
+ it('exports a fixed set of preview keys', () => {
+ expect(PREVIEW_CARD_SAFE_CUSTOM_PROPERTIES.has('--sable-primary-main')).toBe(true);
+ expect(PREVIEW_CARD_SAFE_CUSTOM_PROPERTIES.size).toBeGreaterThan(0);
+ });
+});
diff --git a/src/app/theme/previewCss.ts b/src/app/theme/previewCss.ts
new file mode 100644
index 000000000..7232fffd1
--- /dev/null
+++ b/src/app/theme/previewCss.ts
@@ -0,0 +1,44 @@
+const PROP_RE = /^\s*(--(?:sable|tc)-[a-zA-Z0-9-]+)\s*:\s*([^;]+?)\s*;?\s*$/;
+
+const DANGEROUS_VALUE = /url\s*\(|@import|expression\s*\(|javascript:|\\0|