Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
02e6403
initial github repo theme fetching logic
7w1 Apr 2, 2026
eba0a4c
add author field
7w1 Apr 2, 2026
c82d2ba
theme file injection
7w1 Apr 2, 2026
f395e64
remove themes
7w1 Apr 2, 2026
cdfdb0d
wip theme selector/loader
7w1 Apr 2, 2026
669f58d
fix theme injection
7w1 Apr 2, 2026
310be03
redo settings and formatting things
7w1 Apr 2, 2026
39f7f06
chat preview embeds
7w1 Apr 2, 2026
0d55ded
warning banner and rendering for 3rd party themes and migration syste…
7w1 Apr 2, 2026
d9c2dbb
clean up settings ui and add theme import
7w1 Apr 2, 2026
75209e1
tests and stricter metadata parsing
7w1 Apr 2, 2026
2667d27
Potential fix for pull request finding 'CodeQL / Bad HTML filtering r…
7w1 Apr 2, 2026
c31a298
formatting
7w1 Apr 2, 2026
f60245b
query catalog file instead of gh api
7w1 Apr 2, 2026
590f25e
remove legacyids parsing
7w1 Apr 2, 2026
a495250
skip parsing fullurl for uploaded files if it fails
7w1 Apr 2, 2026
19a6307
formatting
7w1 Apr 2, 2026
54d45b5
tweaks and button formatting consistency
7w1 Apr 4, 2026
22ef414
formatting
7w1 Apr 4, 2026
30ae401
Merge branch 'dev' into feat/theme-edits-v2
7w1 Apr 4, 2026
4b17220
fix test
7w1 Apr 4, 2026
0892b94
remove unnecessary migration logic
7w1 Apr 4, 2026
1090de9
fix typecheck issues
7w1 Apr 20, 2026
ddaa9cd
better typecheck fix
7w1 Apr 20, 2026
2d1b539
Merge branch 'dev' of github.com:SableClient/Sable into feat/theme-ed…
7w1 Apr 24, 2026
94344e6
ensure favorites is not undefined
nushea Apr 29, 2026
1a6d6f4
Merge branch 'dev' into feat/theme-edits-v2
7w1 May 5, 2026
6ee0b99
fix linting
7w1 May 5, 2026
e635e9d
Remove the no preview tokens in this tweak text
7w1 May 5, 2026
8ff6ff6
Improve initial enable flow
7w1 May 5, 2026
eeab65b
Consolidate theme cards a little
7w1 May 5, 2026
bc4ca6e
Changeset and formatting
7w1 May 5, 2026
a4cd4e8
Remove relic of a bygone era
7w1 May 5, 2026
e5bc629
reverse theme source approval logic, empty is none
7w1 May 5, 2026
af52a55
Improve theme loading disclaimers and chat preview setting options
7w1 May 5, 2026
c87630e
add export button and update changeset
7w1 May 5, 2026
e6a7746
Error message for theme favoriting errors
7w1 May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/aadd_theme_catalog_remote_and_migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
default: minor
---

# Themes and tweaks from the catalog

Themes are pulled from [a repo](https://github.com/SableClient/themes) now, so you get the full power of CSS instead of a palette. Tweaks are new: CSS overlays that sit on top of whatever theme you are using.

You'll be prompted to migrate to the new system whenever you update, if you choose not to, you'll be limited to the basic dark/light themes. A few additional themes have been added (Rose Pine variantes, Catpuccin) along with some basic tweaks (circular avatars, monochrome avatars, and square stuff).

You can share themes and tweaks. For themes uploaded online, simply hit the copy button in settings and paste the link in chat. If the setting is enabled, a preview will be generated. Third party themes (as defined by the config.json) have prominent warning banners and fetching is disabled by default.

You can also export and share theme files directly, although no previews are generated for these.

If you're intrested in getting a theme or tweak added to the official catalog, contribute to the themes repo linked above! We're eager to add more!
3 changes: 3 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"webPushAppID": "moe.sable.app.sygnal"
},

"themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/",
"themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"],

"slidingSync": {
"enabled": true
},
Expand Down
39 changes: 36 additions & 3 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,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';
Expand Down Expand Up @@ -63,6 +71,10 @@ const getMediaType = (url: string) => {
return null;
};

const isSableChatEmbedCandidate = (url: string): boolean =>
/^https:\/\//i.test(url) &&
(/\.preview\.sable\.css(\?|#|$)/i.test(url) || isHttpsFullSableCssUrl(url));

const CAPTION_STYLE = { marginTop: config.space.S200 };

function RenderMessageContentInternal({
Expand All @@ -85,6 +97,7 @@ function RenderMessageContentInternal({

const [autoplayGifs] = useSetting(settingsAtom, 'autoplayGifs');
const [captionPosition] = useSetting(settingsAtom, 'captionPosition');
const [themeChatSableWidgets] = useSetting(settingsAtom, 'themeChatSableWidgetsEnabled');
const [multiplePreviews] = useSetting(settingsAtom, 'multiplePreviews');
const settingsLinkBaseUrl = useSettingsLinkBaseUrl();
const captionPositionMap = {
Expand Down Expand Up @@ -115,6 +128,17 @@ function RenderMessageContentInternal({
);
if (filteredUrls.length === 0) return undefined;

const themePreviewUrls = themeChatSableWidgets
? filteredUrls.filter(
(u) => /^https:\/\//i.test(u) && /\.preview\.sable\.css(\?|#|$)/i.test(u)
)
: [];
const themeToRender = themePreviewUrls.filter((u) => /^https:\/\//i.test(u));

const tweakCandidateUrls = themeChatSableWidgets
? filteredUrls.filter((u) => isHttpsFullSableCssUrl(u))
: [];

const analyzed = filteredUrls.map((url) => ({
url,
type: getMediaType(url),
Expand All @@ -124,11 +148,20 @@ function RenderMessageContentInternal({
const toRender = multiplePreviews ? previewCandidates : [previewCandidates[0]!];
return (
<UrlPreviewHolder>
{themeToRender.map((url) => (
<ThemePreviewUrlCard key={`theme:${url}`} url={url} />
))}
{tweakCandidateUrls.map((url) => (
<TweakPreviewUrlCard key={`tweak:${url}`} url={url} />
))}
{toRender.map((item) => {
const { url, type } = item;
if (themeToRender.includes(url)) return null;
if (tweakCandidateUrls.includes(url)) return null;
if (type) {
return <UrlPreviewCard urlPreview key={url} url={url} ts={ts} mediaType={type} />;
}
if (!themeChatSableWidgets && isSableChatEmbedCandidate(url)) return null;
if (clientUrlPreview && youtubeUrl(url)) {
return <ClientPreview key={url} url={url} />;
}
Expand All @@ -140,7 +173,7 @@ function RenderMessageContentInternal({
</UrlPreviewHolder>
);
},
[multiplePreviews, settingsLinkBaseUrl, clientUrlPreview, urlPreview, ts]
[multiplePreviews, themeChatSableWidgets, settingsLinkBaseUrl, clientUrlPreview, urlPreview, ts]
);
const renderBundledPreviews = useCallback(
(bundles: IPreviewUrlResponse[]) => (
Expand All @@ -157,7 +190,7 @@ function RenderMessageContentInternal({
),
[urlPreview]
);
const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined;
const messageUrlsPreview = urlPreview || themeChatSableWidgets ? renderUrlsPreview : undefined;
const messageBundlePreview = bundledPreview ? renderBundledPreviews : undefined;

const renderCaption = () => {
Expand Down
163 changes: 163 additions & 0 deletions src/app/components/theme/ThemeMigrationBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: dismissSafe,
clickOutsideDeactivates: false,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" aria-labelledby="theme-migration-title">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="theme-migration-title" size="H4">
Update your theme selection
</Text>
</Box>
<IconButton
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
onClick={dismissSafe}
disabled={busy}
aria-label="Close"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400">
Older bundled color themes are no longer included in the app. Migrate to the same
looks from the official catalog (downloaded and cached on this device), or dismiss
this reminder.
</Text>
{error && (
<Text size="T300" priority="400" style={{ color: 'var(--sable-error)' }}>
{error}
</Text>
)}
<Box direction="Column" gap="200">
<Button
variant="Primary"
fill="Soft"
outlined
size="300"
radii="300"
onClick={migrate}
disabled={busy}
>
<Text size="B400">{busy ? 'Migrating…' : 'Migrate'}</Text>
</Button>
<Button
variant="Secondary"
fill="Soft"
outlined
size="300"
radii="300"
onClick={dismissSafe}
disabled={busy}
>
<Text size="B400">Not now</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
Loading
Loading