From c78beb8cb51c2cad0eeb018d8cc30400fc6615b2 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 01:07:53 +0100 Subject: [PATCH 01/23] feat: add arborium runtime wrapper --- package.json | 1 + pnpm-lock.yaml | 18 +++++----- src/app/plugins/arborium/index.ts | 2 ++ src/app/plugins/arborium/runtime.test.ts | 44 ++++++++++++++++++++++++ src/app/plugins/arborium/runtime.ts | 41 ++++++++++++++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 src/app/plugins/arborium/index.ts create mode 100644 src/app/plugins/arborium/runtime.test.ts create mode 100644 src/app/plugins/arborium/runtime.ts diff --git a/package.json b/package.json index 23853c9e2..3fc65f9d7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "author": "7w1", "license": "AGPL-3.0-only", "dependencies": { + "@arborium/arborium": "^2.16.0", "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0a9719bc..db54f3ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: .: dependencies: + '@arborium/arborium': + specifier: ^2.16.0 + version: 2.16.0 '@atlaskit/pragmatic-drag-and-drop': specifier: ^1.7.7 version: 1.7.9 @@ -346,6 +349,9 @@ packages: peerDependencies: ajv: '>=8' + '@arborium/arborium@2.16.0': + resolution: {integrity: sha512-9rz2J9Hx+nMq1qon65SbmE+XZvwr/oDqYPinj+BnYOzef7lGwzn1GtYhuB1Cz8jTZ84wIaBt+B8nTQEGYniqNg==} + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3322,10 +3328,6 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - cloudflared@0.7.1: resolution: {integrity: sha512-jJn1Gu9Tf4qnIu8tfiHZ25Hs8rNcRYSVf8zAd97wvYdOCzftm1CTs1S/RPhijjGi8gUT1p9yzfDi9zYlU/0RwA==} hasBin: true @@ -5707,6 +5709,8 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@arborium/arborium@2.16.0': {} + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -9088,12 +9092,6 @@ snapshots: classnames@2.5.1: {} - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - cloudflared@0.7.1: {} clsx@2.1.1: {} diff --git a/src/app/plugins/arborium/index.ts b/src/app/plugins/arborium/index.ts new file mode 100644 index 000000000..d0b5ea449 --- /dev/null +++ b/src/app/plugins/arborium/index.ts @@ -0,0 +1,2 @@ +export type { HighlightCodeInput } from './runtime'; +export { highlightCode } from './runtime'; diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts new file mode 100644 index 000000000..279d60748 --- /dev/null +++ b/src/app/plugins/arborium/runtime.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@arborium/arborium'); + +describe('highlightCode', () => { + afterEach(() => { + vi.resetModules(); + }); + + it('returns escaped code when Arborium cannot be loaded', async () => { + vi.doMock('@arborium/arborium', () => { + throw new Error('boom'); + }); + + const { highlightCode } = await import('.'); + + await expect( + highlightCode({ + code: 'hi', + language: 'typescript', + }) + ).resolves.toBe('<span class="x">hi</span>'); + }); + + it('delegates to Arborium highlight when the module loads', async () => { + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + + vi.doMock('@arborium/arborium', () => ({ + highlight, + })); + + const { highlightCode } = await import('.'); + + await expect( + highlightCode({ + code: 'const value = 1;', + language: 'typescript', + }) + ).resolves.toBe('
const value = 1;
'); + expect(highlight).toHaveBeenCalledWith('typescript', 'const value = 1;'); + }); +}); diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts new file mode 100644 index 000000000..a0019a320 --- /dev/null +++ b/src/app/plugins/arborium/runtime.ts @@ -0,0 +1,41 @@ +type ArboriumModule = typeof import('@arborium/arborium'); + +export interface HighlightCodeInput { + code: string; + language?: string | null; +} + +let arboriumModulePromise: Promise | null = null; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +async function loadArborium(): Promise { + if (!arboriumModulePromise) { + arboriumModulePromise = import('@arborium/arborium').catch(() => null); + } + + return arboriumModulePromise; +} + +export async function highlightCode({ code, language }: HighlightCodeInput): Promise { + if (!language) { + return escapeHtml(code); + } + + const arborium = await loadArborium(); + if (!arborium) { + return escapeHtml(code); + } + + try { + return await arborium.highlight(language, code); + } catch { + return escapeHtml(code); + } +} From 286b85c434c351679733c33ba31225822c9545f7 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 01:17:02 +0100 Subject: [PATCH 02/23] fix: align arborium runtime api with spec --- src/app/plugins/arborium/index.ts | 2 +- src/app/plugins/arborium/runtime.test.ts | 133 +++++++++++++++++++---- src/app/plugins/arborium/runtime.ts | 73 +++++++++++-- 3 files changed, 174 insertions(+), 34 deletions(-) diff --git a/src/app/plugins/arborium/index.ts b/src/app/plugins/arborium/index.ts index d0b5ea449..60ce14ddc 100644 --- a/src/app/plugins/arborium/index.ts +++ b/src/app/plugins/arborium/index.ts @@ -1,2 +1,2 @@ -export type { HighlightCodeInput } from './runtime'; +export type { HighlightCodeInput, HighlightResult } from './runtime'; export { highlightCode } from './runtime'; diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts index 279d60748..15627cd29 100644 --- a/src/app/plugins/arborium/runtime.test.ts +++ b/src/app/plugins/arborium/runtime.test.ts @@ -1,44 +1,129 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -vi.unmock('@arborium/arborium'); +import type { HighlightCodeInput, HighlightResult } from '.'; + +type ArboriumModule = + NonNullable extends () => Promise ? T : never; + +afterEach(() => { + vi.resetModules(); +}); describe('highlightCode', () => { - afterEach(() => { - vi.resetModules(); - }); + it('normalizes explicit aliases before highlighting', async () => { + const normalizeLanguage = vi.fn((language: string) => + language === 'ts' ? 'typescript' : language + ); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); - it('returns escaped code when Arborium cannot be loaded', async () => { - vi.doMock('@arborium/arborium', () => { - throw new Error('boom'); + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode({ + code: 'const value = 1;', + language: 'ts', + allowDetect: false, + loadModule, }); + expect(result).toEqual({ + mode: 'highlighted', + html: '
const value = 1;
', + language: 'typescript', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('ts'); + expect(detectLanguage).not.toHaveBeenCalled(); + expect(highlight).toHaveBeenCalledWith('typescript', 'const value = 1;'); + }); + + it('does not detect a language when allowDetect is false', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => 'javascript'); + const highlight = vi.fn(async () => '
');
+    const module = {
+      normalizeLanguage,
+      detectLanguage,
+      highlight,
+    } as unknown as ArboriumModule;
+    const loadModule = vi.fn(async () => module);
+
     const { highlightCode } = await import('.');
 
-    await expect(
-      highlightCode({
-        code: 'hi',
-        language: 'typescript',
-      })
-    ).resolves.toBe('<span class="x">hi</span>');
+    const result: HighlightResult = await highlightCode({
+      code: 'hello',
+      allowDetect: false,
+      loadModule,
+    });
+
+    expect(result).toEqual({
+      mode: 'plain',
+      html: '<b>hello</b>',
+      language: null,
+    });
+    expect(normalizeLanguage).not.toHaveBeenCalled();
+    expect(detectLanguage).not.toHaveBeenCalled();
+    expect(highlight).not.toHaveBeenCalled();
   });
 
-  it('delegates to Arborium highlight when the module loads', async () => {
+  it('detects a language only when allowDetect is true', async () => {
+    const normalizeLanguage = vi.fn((language: string) =>
+      language === 'js' ? 'javascript' : language
+    );
+    const detectLanguage = vi.fn(() => 'js');
     const highlight = vi.fn(
       async (language: string, code: string) => `
${code}
` ); - - vi.doMock('@arborium/arborium', () => ({ + const module = { + normalizeLanguage, + detectLanguage, highlight, - })); + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); const { highlightCode } = await import('.'); - await expect( - highlightCode({ - code: 'const value = 1;', - language: 'typescript', - }) - ).resolves.toBe('
const value = 1;
'); - expect(highlight).toHaveBeenCalledWith('typescript', 'const value = 1;'); + const result: HighlightResult = await highlightCode({ + code: 'const value = 1;', + allowDetect: true, + loadModule, + }); + + expect(result).toEqual({ + mode: 'highlighted', + html: '
const value = 1;
', + language: 'javascript', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('js'); + expect(detectLanguage).toHaveBeenCalledWith('const value = 1;'); + expect(highlight).toHaveBeenCalledWith('javascript', 'const value = 1;'); + }); + + it('returns plain escaped code when Arborium fails to load', async () => { + const loadModule = vi.fn(async () => { + throw new Error('boom'); + }); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode({ + code: 'hi', + language: 'typescript', + allowDetect: false, + loadModule, + }); + + expect(result).toEqual({ + mode: 'plain', + html: '<span class="x">hi</span>', + language: null, + }); }); }); diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts index a0019a320..06f3d0fd1 100644 --- a/src/app/plugins/arborium/runtime.ts +++ b/src/app/plugins/arborium/runtime.ts @@ -3,6 +3,14 @@ type ArboriumModule = typeof import('@arborium/arborium'); export interface HighlightCodeInput { code: string; language?: string | null; + allowDetect?: boolean; + loadModule?: () => Promise; +} + +export interface HighlightResult { + mode: 'highlighted' | 'plain'; + html: string; + language: string | null; } let arboriumModulePromise: Promise | null = null; @@ -15,7 +23,25 @@ function escapeHtml(text: string): string { .replace(/"/g, '"'); } -async function loadArborium(): Promise { +function plainResult(code: string): HighlightResult { + return { + mode: 'plain', + html: escapeHtml(code), + language: null, + }; +} + +async function loadArborium( + loadModule?: () => Promise +): Promise { + if (loadModule) { + try { + return await loadModule(); + } catch { + return null; + } + } + if (!arboriumModulePromise) { arboriumModulePromise = import('@arborium/arborium').catch(() => null); } @@ -23,19 +49,48 @@ async function loadArborium(): Promise { return arboriumModulePromise; } -export async function highlightCode({ code, language }: HighlightCodeInput): Promise { - if (!language) { - return escapeHtml(code); +export async function highlightCode({ + code, + language, + allowDetect = false, + loadModule, +}: HighlightCodeInput): Promise { + const arborium = await loadArborium(loadModule); + if (!arborium) { + return plainResult(code); } - const arborium = await loadArborium(); - if (!arborium) { - return escapeHtml(code); + let resolvedLanguage: string | null = null; + + if (language) { + try { + resolvedLanguage = arborium.normalizeLanguage(language); + } catch { + return plainResult(code); + } + } else if (allowDetect) { + try { + const detectedLanguage = arborium.detectLanguage(code); + if (detectedLanguage) { + resolvedLanguage = arborium.normalizeLanguage(detectedLanguage); + } + } catch { + return plainResult(code); + } + } + + if (!resolvedLanguage) { + return plainResult(code); } try { - return await arborium.highlight(language, code); + const html = await arborium.highlight(resolvedLanguage, code); + return { + mode: 'highlighted', + html, + language: resolvedLanguage, + }; } catch { - return escapeHtml(code); + return plainResult(code); } } From 0e0290f557df28872630e7adf4ae8b549091fc53 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 01:27:23 +0100 Subject: [PATCH 03/23] fix: align arborium runtime api with spec --- src/app/plugins/arborium/index.ts | 2 +- src/app/plugins/arborium/runtime.test.ts | 92 +++++++++++++++++------- src/app/plugins/arborium/runtime.ts | 43 ++++++----- 3 files changed, 91 insertions(+), 46 deletions(-) diff --git a/src/app/plugins/arborium/index.ts b/src/app/plugins/arborium/index.ts index 60ce14ddc..4216387d9 100644 --- a/src/app/plugins/arborium/index.ts +++ b/src/app/plugins/arborium/index.ts @@ -1,2 +1,2 @@ -export type { HighlightCodeInput, HighlightResult } from './runtime'; +export type { HighlightCodeDeps, HighlightCodeInput, HighlightResult } from './runtime'; export { highlightCode } from './runtime'; diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts index 15627cd29..60af347c1 100644 --- a/src/app/plugins/arborium/runtime.test.ts +++ b/src/app/plugins/arborium/runtime.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { HighlightCodeInput, HighlightResult } from '.'; +import type { HighlightResult } from '.'; -type ArboriumModule = - NonNullable extends () => Promise ? T : never; +type ArboriumModule = typeof import('@arborium/arborium'); afterEach(() => { vi.resetModules(); @@ -27,12 +26,14 @@ describe('highlightCode', () => { const { highlightCode } = await import('.'); - const result: HighlightResult = await highlightCode({ - code: 'const value = 1;', - language: 'ts', - allowDetect: false, - loadModule, - }); + const result: HighlightResult = await highlightCode( + { + code: 'const value = 1;', + language: 'ts', + allowDetect: false, + }, + { loadModule } + ); expect(result).toEqual({ mode: 'highlighted', @@ -57,17 +58,19 @@ describe('highlightCode', () => { const { highlightCode } = await import('.'); - const result: HighlightResult = await highlightCode({ - code: 'hello', - allowDetect: false, - loadModule, - }); + const result: HighlightResult = await highlightCode( + { + code: 'hello', + allowDetect: false, + }, + { loadModule } + ); expect(result).toEqual({ mode: 'plain', html: '<b>hello</b>', - language: null, }); + expect(result.language).toBeUndefined(); expect(normalizeLanguage).not.toHaveBeenCalled(); expect(detectLanguage).not.toHaveBeenCalled(); expect(highlight).not.toHaveBeenCalled(); @@ -90,11 +93,13 @@ describe('highlightCode', () => { const { highlightCode } = await import('.'); - const result: HighlightResult = await highlightCode({ - code: 'const value = 1;', - allowDetect: true, - loadModule, - }); + const result: HighlightResult = await highlightCode( + { + code: 'const value = 1;', + allowDetect: true, + }, + { loadModule } + ); expect(result).toEqual({ mode: 'highlighted', @@ -113,17 +118,52 @@ describe('highlightCode', () => { const { highlightCode } = await import('.'); - const result: HighlightResult = await highlightCode({ - code: 'hi', + const result: HighlightResult = await highlightCode( + { + code: 'hi', + language: 'typescript', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'plain', + html: '<span class="x">hi</span>', language: 'typescript', - allowDetect: false, - loadModule, }); + }); + + it('returns plain escaped code with the resolved language when highlighting fails', async () => { + const normalizeLanguage = vi.fn((language: string) => + language === 'ts' ? 'typescript' : language + ); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn(async () => { + throw new Error('bad highlight'); + }); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '', + language: 'ts', + }, + { loadModule } + ); expect(result).toEqual({ mode: 'plain', - html: '<span class="x">hi</span>', - language: null, + html: '<span>', + language: 'typescript', }); + expect(highlight).toHaveBeenCalledWith('typescript', ''); }); }); diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts index 06f3d0fd1..39e2211b2 100644 --- a/src/app/plugins/arborium/runtime.ts +++ b/src/app/plugins/arborium/runtime.ts @@ -4,13 +4,14 @@ export interface HighlightCodeInput { code: string; language?: string | null; allowDetect?: boolean; - loadModule?: () => Promise; } -export interface HighlightResult { - mode: 'highlighted' | 'plain'; - html: string; - language: string | null; +export type HighlightResult = + | { mode: 'plain'; html: string; language?: string } + | { mode: 'highlighted'; html: string; language: string }; + +export interface HighlightCodeDeps { + loadModule?: () => Promise; } let arboriumModulePromise: Promise | null = null; @@ -23,12 +24,17 @@ function escapeHtml(text: string): string { .replace(/"/g, '"'); } -function plainResult(code: string): HighlightResult { - return { +function plainResult(code: string, language?: string): HighlightResult { + const result: HighlightResult = { mode: 'plain', html: escapeHtml(code), - language: null, }; + + if (language !== undefined) { + result.language = language; + } + + return result; } async function loadArborium( @@ -49,15 +55,14 @@ async function loadArborium( return arboriumModulePromise; } -export async function highlightCode({ - code, - language, - allowDetect = false, - loadModule, -}: HighlightCodeInput): Promise { +export async function highlightCode( + { code, language, allowDetect = false }: HighlightCodeInput, + deps?: HighlightCodeDeps +): Promise { + const { loadModule } = deps ?? {}; const arborium = await loadArborium(loadModule); if (!arborium) { - return plainResult(code); + return plainResult(code, language ?? undefined); } let resolvedLanguage: string | null = null; @@ -66,7 +71,7 @@ export async function highlightCode({ try { resolvedLanguage = arborium.normalizeLanguage(language); } catch { - return plainResult(code); + return plainResult(code, language ?? undefined); } } else if (allowDetect) { try { @@ -75,12 +80,12 @@ export async function highlightCode({ resolvedLanguage = arborium.normalizeLanguage(detectedLanguage); } } catch { - return plainResult(code); + return plainResult(code, language ?? undefined); } } if (!resolvedLanguage) { - return plainResult(code); + return plainResult(code, language ?? undefined); } try { @@ -91,6 +96,6 @@ export async function highlightCode({ language: resolvedLanguage, }; } catch { - return plainResult(code); + return plainResult(code, resolvedLanguage); } } From bce5b50a243dc47c41ed3dd96a04db8507bfaf22 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 01:34:48 +0100 Subject: [PATCH 04/23] fix: treat escaped arborium output as plain --- src/app/plugins/arborium/runtime.test.ts | 29 ++++++++++++++++++++++++ src/app/plugins/arborium/runtime.ts | 3 +++ 2 files changed, 32 insertions(+) diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts index 60af347c1..81d31e2f4 100644 --- a/src/app/plugins/arborium/runtime.test.ts +++ b/src/app/plugins/arborium/runtime.test.ts @@ -134,6 +134,35 @@ describe('highlightCode', () => { }); }); + it('treats escaped Arborium output as plain fallback', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn(async () => '<span>'); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '', + language: 'typescript', + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'plain', + html: '<span>', + language: 'typescript', + }); + expect(highlight).toHaveBeenCalledWith('typescript', ''); + }); + it('returns plain escaped code with the resolved language when highlighting fails', async () => { const normalizeLanguage = vi.fn((language: string) => language === 'ts' ? 'typescript' : language diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts index 39e2211b2..8f9da59d4 100644 --- a/src/app/plugins/arborium/runtime.ts +++ b/src/app/plugins/arborium/runtime.ts @@ -90,6 +90,9 @@ export async function highlightCode( try { const html = await arborium.highlight(resolvedLanguage, code); + if (html === escapeHtml(code)) { + return plainResult(code, resolvedLanguage); + } return { mode: 'highlighted', html, From 20c941e9518129f871398ece59ba5207b610e6cd Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 01:49:40 +0100 Subject: [PATCH 05/23] test: preserve code language metadata during sanitization --- src/app/utils/sanitize.test.ts | 10 ++++++++++ src/app/utils/sanitize.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/utils/sanitize.test.ts b/src/app/utils/sanitize.test.ts index 52c668e6b..8b25963b6 100644 --- a/src/app/utils/sanitize.test.ts +++ b/src/app/utils/sanitize.test.ts @@ -117,6 +117,16 @@ describe('sanitizeCustomHtml – code block class handling', () => { expect(result).toContain('class="language-typescript"'); }); + it('preserves data-lang on code blocks', () => { + const result = sanitizeCustomHtml('const x = 1;'); + expect(result).toContain('data-lang="typescript"'); + }); + + it('preserves data-lang on pre blocks', () => { + const result = sanitizeCustomHtml('
fn main() {}
'); + expect(result).toContain('data-lang="rust"'); + }); + it('strips arbitrary classes not matching language-*', () => { const result = sanitizeCustomHtml('code'); expect(result).not.toContain('evil-class'); diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index e862fa9f3..0a599c0ba 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -66,12 +66,12 @@ const permittedTagToAttributes = { h4: ['data-md'], h5: ['data-md'], h6: ['data-md'], - pre: ['data-md', 'class'], + pre: ['data-md', 'class', 'data-lang'], ol: ['start', 'type', 'data-md'], ul: ['data-md'], a: ['name', 'target', 'href', 'rel', 'data-md'], img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], - code: ['class', 'data-md'], + code: ['class', 'data-md', 'data-lang'], strong: ['data-md'], i: ['data-md'], em: ['data-md'], From 91489783260cf396d2a442d488476e14c2458e97 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 02:00:01 +0100 Subject: [PATCH 06/23] feat: add arborium theme bridge --- src/app/hooks/useTheme.ts | 22 ++-- src/app/pages/ThemeManager.tsx | 9 +- .../arborium/ArboriumThemeBridge.test.tsx | 66 ++++++++++ .../plugins/arborium/ArboriumThemeBridge.tsx | 123 ++++++++++++++++++ src/app/plugins/arborium/index.ts | 1 + 5 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 src/app/plugins/arborium/ArboriumThemeBridge.test.tsx create mode 100644 src/app/plugins/arborium/ArboriumThemeBridge.tsx diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 735d03301..3492fd185 100755 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -30,62 +30,62 @@ export type Theme = { export const LightTheme: Theme = { id: 'light-theme', kind: ThemeKind.Light, - classNames: ['light-theme', lightTheme, onLightFontWeight, 'prism-light'], + classNames: ['light-theme', lightTheme, onLightFontWeight], }; export const SilverTheme: Theme = { id: 'silver-theme', kind: ThemeKind.Light, - classNames: ['silver-theme', silverTheme, onLightFontWeight, 'prism-light'], + classNames: ['silver-theme', silverTheme, onLightFontWeight], }; export const CinnyLightTheme: Theme = { id: 'cinny-light-theme', kind: ThemeKind.Light, - classNames: ['cinny-light-theme', cinnyLightTheme, onLightFontWeight, 'prism-light'], + classNames: ['cinny-light-theme', cinnyLightTheme, onLightFontWeight], }; export const CinnySilverTheme: Theme = { id: 'cinny-silver-theme', kind: ThemeKind.Light, - classNames: ['cinny-silver-theme', cinnySilverTheme, onLightFontWeight, 'prism-light'], + classNames: ['cinny-silver-theme', cinnySilverTheme, onLightFontWeight], }; export const DarkTheme: Theme = { id: 'dark-theme', kind: ThemeKind.Dark, - classNames: ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['dark-theme', darkTheme, onDarkFontWeight], }; export const ButterTheme: Theme = { id: 'butter-theme', kind: ThemeKind.Dark, - classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['butter-theme', butterTheme, onDarkFontWeight], }; export const RosePineTheme: Theme = { id: 'rose-pine-theme', kind: ThemeKind.Dark, - classNames: ['rose-pine-theme', rosePineTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['rose-pine-theme', rosePineTheme, onDarkFontWeight], }; export const GruvdarkTheme: Theme = { id: 'gruvdark-theme', kind: ThemeKind.Dark, - classNames: ['gruvdark-theme', gruvdarkTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['gruvdark-theme', gruvdarkTheme, onDarkFontWeight], }; export const CinnyDarkTheme: Theme = { id: 'cinny-dark-theme', kind: ThemeKind.Dark, - classNames: ['cinny-dark-theme', cinnyDarkTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['cinny-dark-theme', cinnyDarkTheme, onDarkFontWeight], }; export const AccordTheme: Theme = { id: 'accord-theme', kind: ThemeKind.Dark, - classNames: ['accord-theme', accordTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['accord-theme', accordTheme, onDarkFontWeight], }; export const BlackTheme: Theme = { id: 'black-theme', kind: ThemeKind.Dark, - classNames: ['black-theme', blackTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['black-theme', blackTheme, onDarkFontWeight], }; export const useThemes = (): Theme[] => { diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 90a7e5783..d47b73cbf 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -8,6 +8,7 @@ import { useActiveTheme, useSystemThemeKind, } from '$hooks/useTheme'; +import { ArboriumThemeBridge } from '$plugins/arborium'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -25,7 +26,7 @@ export function UnAuthRouteThemeManager() { } }, [systemThemeKind]); - return null; + return ; } export function AuthRouteThemeManager({ children }: { children: ReactNode }) { @@ -60,5 +61,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, saturation, underlineLinks, reducedMotion]); - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx new file mode 100644 index 000000000..f2f93d492 --- /dev/null +++ b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; + +import { ThemeKind } from '$hooks/useTheme'; + +import { ArboriumThemeBridge, useArboriumThemeStatus } from './ArboriumThemeBridge'; + +function StatusProbe() { + const { ready } = useArboriumThemeStatus(); + + return
{ready ? 'ready' : 'loading'}
; +} + +const pluginVersion = '2.16.0'; +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/base-rustdoc.css`; +const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/one-dark.css`; +const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/github-light.css`; + +afterEach(() => { + document.getElementById('arborium-base')?.remove(); + document.getElementById('arborium-theme')?.remove(); +}); + +describe('ArboriumThemeBridge', () => { + it('injects the base stylesheet once and swaps the theme stylesheet from dark to light', () => { + const { rerender } = render( + + + + ); + + const baseLink = document.getElementById('arborium-base'); + const themeLink = document.getElementById('arborium-theme'); + + expect(baseLink).toBeInstanceOf(HTMLLinkElement); + expect(themeLink).toBeInstanceOf(HTMLLinkElement); + expect(baseLink).toHaveAttribute('href', baseHref); + expect(themeLink).toHaveAttribute('href', darkHref); + expect(document.head.querySelectorAll('#arborium-base')).toHaveLength(1); + expect(document.head.querySelectorAll('#arborium-theme')).toHaveLength(1); + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); + + act(() => { + baseLink?.dispatchEvent(new Event('load')); + themeLink?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('arborium-status')).toHaveTextContent('ready'); + + rerender( + + + + ); + + const nextBaseLink = document.getElementById('arborium-base'); + const nextThemeLink = document.getElementById('arborium-theme'); + + expect(nextBaseLink).toBe(baseLink); + expect(nextThemeLink).toBe(themeLink); + expect(document.head.querySelectorAll('#arborium-base')).toHaveLength(1); + expect(nextBaseLink).toHaveAttribute('href', baseHref); + expect(nextThemeLink).toHaveAttribute('href', lightHref); + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); + }); +}); diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.tsx new file mode 100644 index 000000000..7b7a78c1f --- /dev/null +++ b/src/app/plugins/arborium/ArboriumThemeBridge.tsx @@ -0,0 +1,123 @@ +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { pluginVersion } from '@arborium/arborium'; + +import { ThemeKind } from '$hooks/useTheme'; + +type ArboriumThemeStatus = { + ready: boolean; +}; + +const ArboriumThemeStatusContext = createContext(null); + +export const useArboriumThemeStatus = (): ArboriumThemeStatus => { + const status = useContext(ArboriumThemeStatusContext); + if (!status) { + throw new Error('No Arborium theme status provided!'); + } + + return status; +}; + +type ArboriumThemeBridgeProps = { + kind: ThemeKind; + children?: ReactNode; +}; + +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/base-rustdoc.css`; +const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/one-dark.css`; +const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/github-light.css`; + +const baseLinkId = 'arborium-base'; +const themeLinkId = 'arborium-theme'; + +const getOrCreateLink = (id: string): HTMLLinkElement => { + const existingLink = document.getElementById(id); + if (existingLink instanceof HTMLLinkElement) { + return existingLink; + } + + const link = document.createElement('link'); + link.setAttribute('id', id); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + document.head.append(link); + return link; +}; + +const setLinkHref = (link: HTMLLinkElement, href: string) => { + if (link.getAttribute('href') !== href) { + link.setAttribute('href', href); + } +}; + +const markLinkLoaded = (link: HTMLLinkElement) => { + link.setAttribute('data-arborium-loaded', 'true'); +}; + +const clearLinkLoaded = (link: HTMLLinkElement) => { + link.removeAttribute('data-arborium-loaded'); +}; + +export function ArboriumThemeBridge({ kind, children }: ArboriumThemeBridgeProps) { + const [baseReady, setBaseReady] = useState(false); + const [themeReady, setThemeReady] = useState(false); + + useEffect(() => { + const baseLink = getOrCreateLink(baseLinkId); + setLinkHref(baseLink, baseHref); + setBaseReady(baseLink.dataset.arboriumLoaded === 'true'); + + const handleBaseLoad = () => { + markLinkLoaded(baseLink); + setBaseReady(true); + }; + const handleBaseError = () => { + clearLinkLoaded(baseLink); + setBaseReady(false); + }; + + baseLink.addEventListener('load', handleBaseLoad); + baseLink.addEventListener('error', handleBaseError); + + return () => { + baseLink.removeEventListener('load', handleBaseLoad); + baseLink.removeEventListener('error', handleBaseError); + }; + }, []); + + useEffect(() => { + const themeLink = getOrCreateLink(themeLinkId); + const href = kind === ThemeKind.Dark ? darkHref : lightHref; + const hrefChanged = themeLink.getAttribute('href') !== href; + setLinkHref(themeLink, href); + if (hrefChanged) { + clearLinkLoaded(themeLink); + } + setThemeReady(!hrefChanged && themeLink.dataset.arboriumLoaded === 'true'); + + const handleThemeLoad = () => { + markLinkLoaded(themeLink); + setThemeReady(true); + }; + const handleThemeError = () => { + clearLinkLoaded(themeLink); + setThemeReady(false); + }; + + themeLink.addEventListener('load', handleThemeLoad); + themeLink.addEventListener('error', handleThemeError); + + return () => { + themeLink.removeEventListener('load', handleThemeLoad); + themeLink.removeEventListener('error', handleThemeError); + }; + }, [kind]); + + const status = useMemo(() => ({ ready: baseReady && themeReady }), [baseReady, themeReady]); + + return ( + + {children} + + ); +} diff --git a/src/app/plugins/arborium/index.ts b/src/app/plugins/arborium/index.ts index 4216387d9..8f95a191d 100644 --- a/src/app/plugins/arborium/index.ts +++ b/src/app/plugins/arborium/index.ts @@ -1,2 +1,3 @@ export type { HighlightCodeDeps, HighlightCodeInput, HighlightResult } from './runtime'; export { highlightCode } from './runtime'; +export { ArboriumThemeBridge, useArboriumThemeStatus } from './ArboriumThemeBridge'; From dda1be44be70d8901df58946517d5d2c5dd36e01 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 02:06:17 +0100 Subject: [PATCH 07/23] fix: use arborium dist theme urls --- src/app/plugins/arborium/ArboriumThemeBridge.test.tsx | 6 +++--- src/app/plugins/arborium/ArboriumThemeBridge.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx index f2f93d492..c2bf076f6 100644 --- a/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx +++ b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx @@ -12,9 +12,9 @@ function StatusProbe() { } const pluginVersion = '2.16.0'; -const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/base-rustdoc.css`; -const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/one-dark.css`; -const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/github-light.css`; +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/base-rustdoc.css`; +const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/one-dark.css`; +const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/github-light.css`; afterEach(() => { document.getElementById('arborium-base')?.remove(); diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.tsx index 7b7a78c1f..20e8978e1 100644 --- a/src/app/plugins/arborium/ArboriumThemeBridge.tsx +++ b/src/app/plugins/arborium/ArboriumThemeBridge.tsx @@ -23,9 +23,9 @@ type ArboriumThemeBridgeProps = { children?: ReactNode; }; -const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/base-rustdoc.css`; -const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/one-dark.css`; -const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/themes/github-light.css`; +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/base-rustdoc.css`; +const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/one-dark.css`; +const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/github-light.css`; const baseLinkId = 'arborium-base'; const themeLinkId = 'arborium-theme'; From c3cf613a2cbe122edf7195034dad3d60f1f01da7 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 02:19:06 +0100 Subject: [PATCH 08/23] fix: restore prism body theme classes --- src/app/pages/ThemeManager.test.tsx | 106 ++++++++++++++++++++++++++++ src/app/pages/ThemeManager.tsx | 5 ++ 2 files changed, 111 insertions(+) create mode 100644 src/app/pages/ThemeManager.test.tsx diff --git a/src/app/pages/ThemeManager.test.tsx b/src/app/pages/ThemeManager.test.tsx new file mode 100644 index 000000000..301e90f20 --- /dev/null +++ b/src/app/pages/ThemeManager.test.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +import { ThemeKind, type Theme } from '$hooks/useTheme'; +import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; + +const settings = { + saturationLevel: 100, + underlineLinks: false, + reducedMotion: false, +}; + +let systemThemeKind = ThemeKind.Light; +let activeTheme: Theme = { + id: 'test-light', + kind: ThemeKind.Light, + classNames: ['test-light-theme'], +}; + +type ThemeContextProviderProps = { + value: Theme; + children: ReactNode; +}; + +type ArboriumThemeBridgeProps = { + kind: ThemeKind; + children?: ReactNode; +}; + +vi.mock('$hooks/useTheme', () => ({ + ThemeKind: { + Light: 'light', + Dark: 'dark', + }, + DarkTheme: { + classNames: ['test-dark-theme'], + }, + LightTheme: { + classNames: ['test-light-theme'], + }, + ThemeContextProvider: ({ value, children }: ThemeContextProviderProps) => + value.kind === ThemeKind.Dark ? <>{children} : <>{children}, + useActiveTheme: () => activeTheme, + useSystemThemeKind: () => systemThemeKind, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: keyof typeof settings) => [settings[key]], +})); + +vi.mock('$state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('$plugins/arborium', () => ({ + ArboriumThemeBridge: ({ kind, children }: ArboriumThemeBridgeProps) => + kind === ThemeKind.Dark ? <>{children} : <>{children}, +})); + +beforeEach(() => { + systemThemeKind = ThemeKind.Light; + activeTheme = { + id: 'test-light', + kind: ThemeKind.Light, + classNames: ['test-light-theme'], + }; + settings.saturationLevel = 100; + settings.underlineLinks = false; + settings.reducedMotion = false; + document.body.className = ''; + document.body.style.filter = ''; +}); + +afterEach(() => { + document.body.className = ''; + document.body.style.filter = ''; +}); + +describe('ThemeManager', () => { + it('keeps a Prism compatibility class on the body for unauthenticated routes', () => { + systemThemeKind = ThemeKind.Dark; + + render(); + + expect(document.body).toHaveClass('prism-dark'); + expect(document.body).not.toHaveClass('prism-light'); + }); + + it('keeps a Prism compatibility class on the body for authenticated routes', () => { + activeTheme = { + id: 'test-dark', + kind: ThemeKind.Dark, + classNames: ['test-dark-theme'], + }; + + render( + +
child
+
+ ); + + expect(document.body).toHaveClass('prism-dark'); + expect(document.body).not.toHaveClass('prism-light'); + }); +}); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index d47b73cbf..be43b7336 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -12,6 +12,9 @@ import { ArboriumThemeBridge } from '$plugins/arborium'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +const getPrismCompatibilityClass = (kind: ThemeKind) => + kind === ThemeKind.Dark ? 'prism-dark' : 'prism-light'; + export function UnAuthRouteThemeManager() { const systemThemeKind = useSystemThemeKind(); @@ -24,6 +27,7 @@ export function UnAuthRouteThemeManager() { if (systemThemeKind === ThemeKind.Light) { document.body.classList.add(...LightTheme.classNames); } + document.body.classList.add(getPrismCompatibilityClass(systemThemeKind)); }, [systemThemeKind]); return ; @@ -39,6 +43,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { document.body.className = ''; document.body.classList.add(configClass, varsClass); document.body.classList.add(...activeTheme.classNames); + document.body.classList.add(getPrismCompatibilityClass(activeTheme.kind)); if (underlineLinks) { document.body.classList.add('force-underline-links'); From 4e07ec15dc11909482f716c1b23f5f06f2e00a11 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 10:42:56 +0100 Subject: [PATCH 09/23] feat: add shared arborium code renderer --- .../CodeHighlightRenderer.test.tsx | 88 +++++++++++++++++++ .../code-highlight/CodeHighlightRenderer.tsx | 65 ++++++++++++++ src/app/components/code-highlight/index.ts | 3 + 3 files changed, 156 insertions(+) create mode 100644 src/app/components/code-highlight/CodeHighlightRenderer.test.tsx create mode 100644 src/app/components/code-highlight/CodeHighlightRenderer.tsx create mode 100644 src/app/components/code-highlight/index.ts diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx new file mode 100644 index 000000000..c0bcd8b24 --- /dev/null +++ b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx @@ -0,0 +1,88 @@ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CodeHighlightRenderer } from '.'; + +const { highlightCode, useArboriumThemeStatus } = vi.hoisted(() => ({ + highlightCode: vi.fn(), + useArboriumThemeStatus: vi.fn(), +})); + +vi.mock('$plugins/arborium', () => ({ + highlightCode, + useArboriumThemeStatus, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('CodeHighlightRenderer', () => { + it('renders highlighted HTML when Arborium succeeds and theme is ready', async () => { + highlightCode.mockResolvedValue({ + mode: 'highlighted', + html: 'const value = 1;', + language: 'typescript', + }); + useArboriumThemeStatus.mockReturnValue({ ready: true }); + + const { container } = render( + + ); + + const code = container.querySelector('code'); + expect(code).toHaveClass('code'); + + await waitFor(() => { + expect(code?.innerHTML).toContain('const'); + }); + + expect(code?.innerHTML).toContain('const'); + expect(highlightCode).toHaveBeenCalledWith({ + code: 'const value = 1;', + language: 'ts', + allowDetect: true, + }); + }); + + it('renders plain text when theme is not ready', async () => { + highlightCode.mockResolvedValue({ + mode: 'highlighted', + html: 'const value = 1;', + language: 'typescript', + }); + useArboriumThemeStatus.mockReturnValue({ ready: false }); + + const { container } = render( + + ); + + const code = container.querySelector('code'); + + await waitFor(() => { + expect(code).toHaveTextContent('const value = 1;'); + }); + + expect(code?.innerHTML).toBe('const value = 1;'); + }); + + it('renders plain text when Arborium returns plain mode', async () => { + highlightCode.mockResolvedValue({ + mode: 'plain', + html: 'const value = 1;', + language: 'ts', + }); + useArboriumThemeStatus.mockReturnValue({ ready: true }); + + const { container } = render( + + ); + + const code = container.querySelector('code'); + + await waitFor(() => { + expect(code).toHaveTextContent('const value = 1;'); + }); + + expect(code?.innerHTML).toBe('const value = 1;'); + }); +}); diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx new file mode 100644 index 000000000..bac7cc603 --- /dev/null +++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +import { highlightCode, type HighlightResult, useArboriumThemeStatus } from '$plugins/arborium'; + +type CodeHighlightRendererProps = { + code: string; + language?: string; + allowDetect?: boolean; + className?: string; +}; + +type RenderResult = HighlightResult; + +const createPlainResult = (code: string, language?: string): RenderResult => { + const result: RenderResult = { + mode: 'plain', + html: code, + }; + + if (language !== undefined) { + result.language = language; + } + + return result; +}; + +export function CodeHighlightRenderer({ + code, + language, + allowDetect = false, + className, +}: CodeHighlightRendererProps) { + const { ready } = useArboriumThemeStatus(); + const [result, setResult] = useState(() => createPlainResult(code, language)); + + useEffect(() => { + let cancelled = false; + + setResult(createPlainResult(code, language)); + + highlightCode({ code, language, allowDetect }) + .then((next) => { + if (!cancelled) { + setResult(next); + } + }) + .catch(() => { + if (!cancelled) { + setResult(createPlainResult(code, language)); + } + }); + + return () => { + cancelled = true; + }; + }, [code, language, allowDetect]); + + if (!ready || result.mode === 'plain') { + return {code}; + } + + // Arborium HTML is only rendered after both highlight and theme CSS are ready. + /* eslint-disable-next-line react/no-danger */ + return ; +} diff --git a/src/app/components/code-highlight/index.ts b/src/app/components/code-highlight/index.ts new file mode 100644 index 000000000..7ab7bb6ba --- /dev/null +++ b/src/app/components/code-highlight/index.ts @@ -0,0 +1,3 @@ +export type { HighlightCodeDeps, HighlightCodeInput, HighlightResult } from '$plugins/arborium'; +export { highlightCode, useArboriumThemeStatus } from '$plugins/arborium'; +export { CodeHighlightRenderer } from './CodeHighlightRenderer'; From e74f1672687b786adcc148dd8873961047efd779 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 10:49:09 +0100 Subject: [PATCH 10/23] fix: prevent stale arborium highlight flash --- .../CodeHighlightRenderer.test.tsx | 60 +++++++++++++++++++ .../code-highlight/CodeHighlightRenderer.tsx | 51 +++++++++------- 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx index c0bcd8b24..462bdccdf 100644 --- a/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx +++ b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx @@ -1,4 +1,6 @@ import { render, waitFor } from '@testing-library/react'; +import { flushSync } from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CodeHighlightRenderer } from '.'; @@ -12,6 +14,18 @@ vi.mock('$plugins/arborium', () => ({ useArboriumThemeStatus, })); +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + afterEach(() => { vi.clearAllMocks(); }); @@ -85,4 +99,50 @@ describe('CodeHighlightRenderer', () => { expect(code?.innerHTML).toBe('const value = 1;'); }); + + it('renders plain new code immediately while a new highlight request is pending', async () => { + const firstHighlight = deferred<{ + mode: 'highlighted'; + html: string; + language: string; + }>(); + const secondHighlight = deferred<{ + mode: 'highlighted'; + html: string; + language: string; + }>(); + + highlightCode + .mockReturnValueOnce(firstHighlight.promise) + .mockReturnValueOnce(secondHighlight.promise); + useArboriumThemeStatus.mockReturnValue({ ready: true }); + + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + + flushSync(() => { + root.render(); + }); + + firstHighlight.resolve({ + mode: 'highlighted', + html: 'const alpha = 1;', + language: 'typescript', + }); + + await waitFor(() => { + expect(host.querySelector('code')?.innerHTML).toContain('alpha'); + }); + + flushSync(() => { + root.render(); + }); + + expect(host.querySelector('code')?.innerHTML).toBe('const beta = 2;'); + expect(host.querySelector('code')).not.toContainHTML('alpha'); + + root.unmount(); + host.remove(); + }); }); diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx index bac7cc603..e7c24d9c0 100644 --- a/src/app/components/code-highlight/CodeHighlightRenderer.tsx +++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx @@ -9,10 +9,16 @@ type CodeHighlightRendererProps = { className?: string; }; -type RenderResult = HighlightResult; +type RenderState = { + key: string; + result: HighlightResult; +}; + +const createRequestKey = (code: string, language?: string, allowDetect = false) => + JSON.stringify([code, language ?? null, allowDetect]); -const createPlainResult = (code: string, language?: string): RenderResult => { - const result: RenderResult = { +const createPlainResult = (code: string, language?: string): HighlightResult => { + const result: HighlightResult = { mode: 'plain', html: code, }; @@ -31,35 +37,40 @@ export function CodeHighlightRenderer({ className, }: CodeHighlightRendererProps) { const { ready } = useArboriumThemeStatus(); - const [result, setResult] = useState(() => createPlainResult(code, language)); + const requestKey = createRequestKey(code, language, allowDetect); + const [state, setState] = useState(() => ({ + key: requestKey, + result: createPlainResult(code, language), + })); useEffect(() => { let cancelled = false; - setResult(createPlainResult(code, language)); + setState({ + key: requestKey, + result: createPlainResult(code, language), + }); - highlightCode({ code, language, allowDetect }) - .then((next) => { - if (!cancelled) { - setResult(next); - } - }) - .catch(() => { - if (!cancelled) { - setResult(createPlainResult(code, language)); - } - }); + highlightCode({ code, language, allowDetect }).then((next) => { + if (!cancelled) { + setState({ + key: requestKey, + result: next, + }); + } + }); return () => { cancelled = true; }; - }, [code, language, allowDetect]); + }, [code, language, allowDetect, requestKey]); + + const currentResult = state.key === requestKey ? state.result : createPlainResult(code, language); - if (!ready || result.mode === 'plain') { + if (!ready || currentResult.mode === 'plain') { return {code}; } - // Arborium HTML is only rendered after both highlight and theme CSS are ready. /* eslint-disable-next-line react/no-danger */ - return ; + return ; } From f804f07a3ce7f2a00085b8995a52a5173e8b6bdc Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 10:59:47 +0100 Subject: [PATCH 11/23] feat: render message code blocks with arborium --- .../plugins/react-custom-html-parser.test.tsx | 87 +++++++++++++++++ src/app/plugins/react-custom-html-parser.tsx | 94 ++++++++++--------- 2 files changed, 137 insertions(+), 44 deletions(-) create mode 100644 src/app/plugins/react-custom-html-parser.test.tsx diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx new file mode 100644 index 000000000..00fb0b2cd --- /dev/null +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from '@testing-library/react'; +import parse from 'html-react-parser'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getReactCustomHtmlParser, LINKIFY_OPTS } from './react-custom-html-parser'; + +const { CodeHighlightRenderer } = vi.hoisted(() => ({ + CodeHighlightRenderer: vi.fn( + ({ + code, + language, + allowDetect, + }: { + code: string; + language?: string; + allowDetect?: boolean; + }) => ( + + {code} + + ) + ), +})); + +vi.mock('$components/code-highlight', () => ({ + CodeHighlightRenderer, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +const mx = {} as never; + +const renderMessage = (html: string) => + render( + <> + {parse( + html, + getReactCustomHtmlParser(mx, undefined, { + linkifyOpts: LINKIFY_OPTS, + }) + )} + + ); + +describe('getReactCustomHtmlParser code blocks', () => { + it('renders the Arborium renderer inside the existing code block shell for explicit data-lang metadata', () => { + renderMessage( + `
fn main() {\nlet value = 1;\nlet next = 2;\nlet third = 3;\nlet fourth = 4;\nlet fifth = 5;\nlet sixth = 6;\nlet seventh = 7;\nlet eighth = 8;\nlet ninth = 9;\nlet tenth = 10;\nlet eleventh = 11;\nlet twelfth = 12;\nlet thirteenth = 13;\nlet fourteenth = 14;\nlet fifteenth = 15;\n}
` + ); + + expect(screen.getByText('rust')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + + const arboriumCode = screen.getByTestId('arborium-code'); + expect(arboriumCode).toHaveTextContent('fn main()'); + expect(arboriumCode).toHaveAttribute('data-language', 'rust'); + expect(arboriumCode).toHaveAttribute('data-allow-detect', 'false'); + expect(CodeHighlightRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + code: expect.stringContaining('let fifteenth = 15;'), + language: 'rust', + allowDetect: false, + }), + expect.anything() + ); + }); + + it('falls back to the language class when no explicit data-lang metadata is present', () => { + renderMessage( + `
const value = 1;\nconsole.log(value);
` + ); + + expect(screen.getByText('ts')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + + const shell = screen.getByTestId('arborium-code'); + expect(shell).toHaveTextContent('const value = 1;'); + expect(shell).toHaveAttribute('data-language', 'ts'); + }); +}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 968850480..c4698d341 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,13 +1,5 @@ /* eslint-disable jsx-a11y/alt-text */ -import { - ComponentPropsWithoutRef, - lazy, - ReactEventHandler, - ReactNode, - Suspense, - useMemo, - useState, -} from 'react'; +import { ComponentPropsWithoutRef, ReactEventHandler, ReactNode, useMemo, useState } from 'react'; import { attributesToProps, domToReact, @@ -20,7 +12,6 @@ import classNames from 'classnames'; import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds'; import { IntermediateRepresentation, OptFn, Opts as LinkifyOpts } from 'linkifyjs'; import Linkify from 'linkify-react'; -import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '$styles/CustomHtml.css'; import { @@ -37,6 +28,7 @@ import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; +import { CodeHighlightRenderer } from '$components/code-highlight'; import { parseMatrixToRoom, parseMatrixToRoomEvent, @@ -45,8 +37,6 @@ import { } from './matrix-to'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; -const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); - const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g'); export const LINKIFY_OPTS: LinkifyOpts = { @@ -241,20 +231,40 @@ const extractTextFromChildren = (nodes: ChildNode[]): string => { return text; }; +const getLanguageFromClassName = (className?: string): string | undefined => { + if (!className) return undefined; + + return className + .split(/\s+/) + .find((token) => token.startsWith('language-')) + ?.replace('language-', ''); +}; + +const getCodeBlockLanguage = ( + children: ChildNode[], + attribs?: Record +): string | undefined => { + const code = children[0]; + const codeAttribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined; + + return ( + codeAttribs?.['data-lang'] ?? + attribs?.['data-lang'] ?? + getLanguageFromClassName(codeAttribs?.class) ?? + getLanguageFromClassName(attribs?.class) + ); +}; + export function CodeBlock({ children, + attribs, opts, }: { children: ChildNode[]; + attribs?: Record; opts: HTMLReactParserOptions; }) { - const code = children[0]; - const languageClass = - code instanceof Element && code.name === 'code' ? code.attribs.class : undefined; - const language = - languageClass && languageClass.startsWith('language-') - ? languageClass.replace('language-', '') - : languageClass; + const language = getCodeBlockLanguage(children, attribs); const LINE_LIMIT = 14; const largeCodeBlock = useMemo( @@ -434,7 +444,11 @@ export const getReactCustomHtmlParser = ( } if (name === 'pre') { - return {children}; + return ( + + {children} + + ); } if (name === 'blockquote') { @@ -462,33 +476,25 @@ export const getReactCustomHtmlParser = ( if (name === 'code') { if (parent && 'name' in parent && parent.name === 'pre') { - const codeReact = renderChildren(); - if (typeof codeReact === 'string') { - let lang = typeof props.className === 'string' ? props.className : undefined; - if (lang === 'language-rs') lang = 'language-rust'; - else if (lang === 'language-js') lang = 'language-javascript'; - else if (lang === 'language-ts') lang = 'language-typescript'; - return ( - {codeReact}
}> - {codeReact}
}> - - {(ref) => ( - - {codeReact} - - )} - - - - ); - } - } else { + const language = getCodeBlockLanguage( + [domNode], + parent instanceof Element ? parent.attribs : undefined + ); return ( - - {renderChildren()} - + ); } + + return ( + + {renderChildren()} + + ); } if (name === 'a' && typeof props.href === 'string') { From bfdda6848355f2e476dc758169a91f77cc9203c0 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 11:05:32 +0100 Subject: [PATCH 12/23] fix: handle whitespace in code block language lookup --- src/app/plugins/react-custom-html-parser.test.tsx | 2 +- src/app/plugins/react-custom-html-parser.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 00fb0b2cd..0ecd248fe 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -51,7 +51,7 @@ const renderMessage = (html: string) => describe('getReactCustomHtmlParser code blocks', () => { it('renders the Arborium renderer inside the existing code block shell for explicit data-lang metadata', () => { renderMessage( - `
fn main() {\nlet value = 1;\nlet next = 2;\nlet third = 3;\nlet fourth = 4;\nlet fifth = 5;\nlet sixth = 6;\nlet seventh = 7;\nlet eighth = 8;\nlet ninth = 9;\nlet tenth = 10;\nlet eleventh = 11;\nlet twelfth = 12;\nlet thirteenth = 13;\nlet fourteenth = 14;\nlet fifteenth = 15;\n}
` + `
\n  fn main() {\nlet value = 1;\nlet next = 2;\nlet third = 3;\nlet fourth = 4;\nlet fifth = 5;\nlet sixth = 6;\nlet seventh = 7;\nlet eighth = 8;\nlet ninth = 9;\nlet tenth = 10;\nlet eleventh = 11;\nlet twelfth = 12;\nlet thirteenth = 13;\nlet fourteenth = 14;\nlet fifteenth = 15;\n}\n
` ); expect(screen.getByText('rust')).toBeInTheDocument(); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index c4698d341..0f1c54c95 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -244,8 +244,8 @@ const getCodeBlockLanguage = ( children: ChildNode[], attribs?: Record ): string | undefined => { - const code = children[0]; - const codeAttribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined; + const code = children.find((child) => child instanceof Element && child.name === 'code'); + const codeAttribs = code instanceof Element ? code.attribs : undefined; return ( codeAttribs?.['data-lang'] ?? @@ -477,7 +477,7 @@ export const getReactCustomHtmlParser = ( if (name === 'code') { if (parent && 'name' in parent && parent.name === 'pre') { const language = getCodeBlockLanguage( - [domNode], + parent instanceof Element ? parent.children : [], parent instanceof Element ? parent.attribs : undefined ); return ( From 428505cc21c77a11204a1207cc27530f31389102 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 11:11:44 +0100 Subject: [PATCH 13/23] fix: preserve structured code blocks in parser --- .../plugins/react-custom-html-parser.test.tsx | 26 +++++++++++++++++-- src/app/plugins/react-custom-html-parser.tsx | 7 ++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 0ecd248fe..53e73aab4 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -50,7 +50,7 @@ const renderMessage = (html: string) => describe('getReactCustomHtmlParser code blocks', () => { it('renders the Arborium renderer inside the existing code block shell for explicit data-lang metadata', () => { - renderMessage( + const { container } = renderMessage( `
\n  fn main() {\nlet value = 1;\nlet next = 2;\nlet third = 3;\nlet fourth = 4;\nlet fifth = 5;\nlet sixth = 6;\nlet seventh = 7;\nlet eighth = 8;\nlet ninth = 9;\nlet tenth = 10;\nlet eleventh = 11;\nlet twelfth = 12;\nlet thirteenth = 13;\nlet fourteenth = 14;\nlet fifteenth = 15;\n}\n
` ); @@ -58,7 +58,8 @@ describe('getReactCustomHtmlParser code blocks', () => { expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); - const arboriumCode = screen.getByTestId('arborium-code'); + const arboriumCode = container.querySelector('[data-testid="arborium-code"]'); + expect(arboriumCode).toBeInTheDocument(); expect(arboriumCode).toHaveTextContent('fn main()'); expect(arboriumCode).toHaveAttribute('data-language', 'rust'); expect(arboriumCode).toHaveAttribute('data-allow-detect', 'false'); @@ -72,6 +73,27 @@ describe('getReactCustomHtmlParser code blocks', () => { ); }); + it('preserves nested code children instead of routing them through Arborium', () => { + const { container } = renderMessage(`
\n  alpha
beta
\n
`); + + expect(screen.getByText('Code')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + expect(CodeHighlightRenderer).not.toHaveBeenCalled(); + expect(container.querySelector('br')).toBeInTheDocument(); + expect(container.querySelector('code')).toHaveTextContent('alphabeta'); + }); + + it('uses data-lang on the pre element when the nested code element has no metadata', () => { + renderMessage(`
fn main() {}
`); + + expect(screen.getByText('rust')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + + const shell = screen.getByTestId('arborium-code'); + expect(shell).toHaveTextContent('fn main() {}'); + expect(shell).toHaveAttribute('data-language', 'rust'); + }); + it('falls back to the language class when no explicit data-lang metadata is present', () => { renderMessage( `
const value = 1;\nconsole.log(value);
` diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 0f1c54c95..fce056193 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -476,13 +476,18 @@ export const getReactCustomHtmlParser = ( if (name === 'code') { if (parent && 'name' in parent && parent.name === 'pre') { + const codeContent = renderChildren(); + if (typeof codeContent !== 'string') { + return undefined; + } + const language = getCodeBlockLanguage( parent instanceof Element ? parent.children : [], parent instanceof Element ? parent.attribs : undefined ); return ( Date: Fri, 27 Mar 2026 11:22:29 +0100 Subject: [PATCH 14/23] refactor: replace prism text highlighting with arborium --- package.json | 3 - pnpm-lock.yaml | 30 -- .../text-viewer/TextViewer.test.tsx | 49 +++ src/app/components/text-viewer/TextViewer.tsx | 12 +- src/app/pages/ThemeManager.test.tsx | 8 +- src/app/pages/ThemeManager.tsx | 5 - src/app/plugins/react-prism/ReactPrism.css | 97 ------ src/app/plugins/react-prism/ReactPrism.tsx | 323 ------------------ 8 files changed, 56 insertions(+), 471 deletions(-) create mode 100644 src/app/components/text-viewer/TextViewer.test.tsx delete mode 100644 src/app/plugins/react-prism/ReactPrism.css delete mode 100644 src/app/plugins/react-prism/ReactPrism.tsx diff --git a/package.json b/package.json index 3fc65f9d7..2a017c8ea 100644 --- a/package.json +++ b/package.json @@ -75,13 +75,11 @@ "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "^1.16.1", "pdfjs-dist": "^5.4.624", - "prismjs": "^1.30.0", "react": "^18.3.1", "react-aria": "^3.46.0", "react-blurhash": "^0.3.0", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", - "react-error-boundary": "^4.1.2", "react-google-recaptcha": "^2.1.0", "react-i18next": "^16.5.4", "react-range": "^1.10.0", @@ -111,7 +109,6 @@ "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", "@types/node": "24.10.13", - "@types/prismjs": "^1.26.6", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@types/react-google-recaptcha": "^2.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db54f3ddc..c2f683f59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,9 +150,6 @@ importers: pdfjs-dist: specifier: ^5.4.624 version: 5.5.207 - prismjs: - specifier: ^1.30.0 - version: 1.30.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -168,9 +165,6 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - react-error-boundary: - specifier: ^4.1.2 - version: 4.1.2(react@18.3.1) react-google-recaptcha: specifier: ^2.1.0 version: 2.1.0(react@18.3.1) @@ -253,9 +247,6 @@ importers: '@types/node': specifier: 24.10.13 version: 24.10.13 - '@types/prismjs': - specifier: ^1.26.6 - version: 1.26.6 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -2818,9 +2809,6 @@ packages: '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - '@types/prismjs@1.26.6': - resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} - '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -4773,10 +4761,6 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} - engines: {node: '>=6'} - progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -4825,11 +4809,6 @@ packages: peerDependencies: react: ^18.3.1 - react-error-boundary@4.1.2: - resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==} - peerDependencies: - react: '>=16.13.1' - react-google-recaptcha@2.1.0: resolution: {integrity: sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==} peerDependencies: @@ -8479,8 +8458,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/prismjs@1.26.6': {} - '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -10708,8 +10685,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - prismjs@1.30.0: {} - progress@2.0.3: {} prop-types@15.8.1: @@ -10795,11 +10770,6 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-error-boundary@4.1.2(react@18.3.1): - dependencies: - '@babel/runtime': 7.28.6 - react: 18.3.1 - react-google-recaptcha@2.1.0(react@18.3.1): dependencies: prop-types: 15.8.1 diff --git a/src/app/components/text-viewer/TextViewer.test.tsx b/src/app/components/text-viewer/TextViewer.test.tsx new file mode 100644 index 000000000..8670cdbe3 --- /dev/null +++ b/src/app/components/text-viewer/TextViewer.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TextViewer } from './TextViewer'; + +const { copyToClipboard, CodeHighlightRenderer } = vi.hoisted(() => ({ + copyToClipboard: vi.fn(), + CodeHighlightRenderer: vi.fn(({ code, language, allowDetect }) => ( + + {code} + + )), +})); + +vi.mock('$utils/dom', () => ({ + copyToClipboard, +})); + +vi.mock('$components/code-highlight', () => ({ + CodeHighlightRenderer, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('TextViewer', () => { + it('uses the shared code highlight renderer and keeps Copy All working', async () => { + const user = userEvent.setup(); + + render( + + ); + + expect(CodeHighlightRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'line 1\nline 2', + language: 'txt', + allowDetect: true, + }), + {} + ); + + await user.click(screen.getByText('Copy All')); + + expect(copyToClipboard).toHaveBeenCalledWith('line 1\nline 2'); + expect(screen.getByTestId('highlight')).toHaveTextContent('line 1 line 2'); + }); +}); diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index 8c6957ec8..c2d06937c 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -1,12 +1,10 @@ -import { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react'; +import { ComponentProps, HTMLAttributes, forwardRef } from 'react'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds'; -import { ErrorBoundary } from 'react-error-boundary'; +import { CodeHighlightRenderer } from '$components/code-highlight'; import { copyToClipboard } from '$utils/dom'; import * as css from './TextViewer.css'; -const ReactPrism = lazy(() => import('$plugins/react-prism/ReactPrism')); - type TextViewerContentProps = { text: string; langName: string; @@ -21,11 +19,7 @@ export const TextViewerContent = forwardRef - {text}
}> - {text}}> - {(codeRef) => {text}} - - + ) ); diff --git a/src/app/pages/ThemeManager.test.tsx b/src/app/pages/ThemeManager.test.tsx index 301e90f20..d0afeb2d3 100644 --- a/src/app/pages/ThemeManager.test.tsx +++ b/src/app/pages/ThemeManager.test.tsx @@ -78,16 +78,16 @@ afterEach(() => { }); describe('ThemeManager', () => { - it('keeps a Prism compatibility class on the body for unauthenticated routes', () => { + it('does not add Prism compatibility classes for unauthenticated routes', () => { systemThemeKind = ThemeKind.Dark; render(); - expect(document.body).toHaveClass('prism-dark'); + expect(document.body).not.toHaveClass('prism-dark'); expect(document.body).not.toHaveClass('prism-light'); }); - it('keeps a Prism compatibility class on the body for authenticated routes', () => { + it('does not add Prism compatibility classes for authenticated routes', () => { activeTheme = { id: 'test-dark', kind: ThemeKind.Dark, @@ -100,7 +100,7 @@ describe('ThemeManager', () => { ); - expect(document.body).toHaveClass('prism-dark'); + expect(document.body).not.toHaveClass('prism-dark'); expect(document.body).not.toHaveClass('prism-light'); }); }); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index be43b7336..d47b73cbf 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -12,9 +12,6 @@ import { ArboriumThemeBridge } from '$plugins/arborium'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -const getPrismCompatibilityClass = (kind: ThemeKind) => - kind === ThemeKind.Dark ? 'prism-dark' : 'prism-light'; - export function UnAuthRouteThemeManager() { const systemThemeKind = useSystemThemeKind(); @@ -27,7 +24,6 @@ export function UnAuthRouteThemeManager() { if (systemThemeKind === ThemeKind.Light) { document.body.classList.add(...LightTheme.classNames); } - document.body.classList.add(getPrismCompatibilityClass(systemThemeKind)); }, [systemThemeKind]); return ; @@ -43,7 +39,6 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { document.body.className = ''; document.body.classList.add(configClass, varsClass); document.body.classList.add(...activeTheme.classNames); - document.body.classList.add(getPrismCompatibilityClass(activeTheme.kind)); if (underlineLinks) { document.body.classList.add('force-underline-links'); diff --git a/src/app/plugins/react-prism/ReactPrism.css b/src/app/plugins/react-prism/ReactPrism.css deleted file mode 100644 index e6a121771..000000000 --- a/src/app/plugins/react-prism/ReactPrism.css +++ /dev/null @@ -1,97 +0,0 @@ -.prism-light { - --prism-comment: #0f4777; - --prism-punctuation: #6d5050; - --prism-property: #9b1144; - --prism-boolean: #4816a3; - --prism-selector: #659604; - --prism-operator: #2a2a2a; - --prism-atrule: #7e6d00; - --prism-keyword: #00829f; - --prism-regex: #9b6426; -} - -.prism-dark { - --prism-comment: #8292a2; - --prism-punctuation: #f8f8f2; - --prism-property: #f92672; - --prism-boolean: #ae81ff; - --prism-selector: #a6e22e; - --prism-operator: #f8f8f2; - --prism-atrule: #e6db74; - --prism-keyword: #66d9ef; - --prism-regex: #fd971f; -} - -code .token.comment, -code .token.prolog, -code .token.doctype, -code .token.cdata { - color: var(--prism-comment); -} - -code .token.punctuation { - color: var(--prism-punctuation); -} - -code .token.namespace { - opacity: 0.7; -} - -code .token.property, -code .token.tag, -code .token.constant, -code .token.symbol, -code .token.deleted { - color: var(--prism-property); -} - -code .token.boolean, -code .token.number { - color: var(--prism-boolean); -} - -code .token.selector, -code .token.attr-name, -code .token.string, -code .token.char, -code .token.builtin, -code .token.inserted { - color: var(--prism-selector); -} - -code .token.operator, -code .token.entity, -code .token.url, -.language-css code .token.string, -.style code .token.string, -code .token.variable { - color: var(--prism-operator); -} - -code .token.atrule, -code .token.attr-value, -code .token.function, -code .token.class-name { - color: var(--prism-atrule); -} - -code .token.keyword { - color: var(--prism-keyword); -} - -code .token.regex, -code .token.important { - color: var(--prism-regex); -} - -code .token.important, -code .token.bold { - font-weight: bold; -} -code .token.italic { - font-style: italic; -} - -code .token.entity { - cursor: help; -} diff --git a/src/app/plugins/react-prism/ReactPrism.tsx b/src/app/plugins/react-prism/ReactPrism.tsx deleted file mode 100644 index 1d3bb90fa..000000000 --- a/src/app/plugins/react-prism/ReactPrism.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { MutableRefObject, ReactNode, useEffect, useRef } from 'react'; - -import Prism from 'prismjs'; - -import 'prismjs/components/prism-abap.js'; -import 'prismjs/components/prism-abnf.js'; -import 'prismjs/components/prism-actionscript.js'; -import 'prismjs/components/prism-ada.js'; -import 'prismjs/components/prism-agda.js'; -import 'prismjs/components/prism-al.js'; -import 'prismjs/components/prism-antlr4.js'; -import 'prismjs/components/prism-apacheconf.js'; -import 'prismjs/components/prism-apex.js'; -import 'prismjs/components/prism-apl.js'; -import 'prismjs/components/prism-applescript.js'; -import 'prismjs/components/prism-aql.js'; -import 'prismjs/components/prism-arff.js'; -import 'prismjs/components/prism-armasm.js'; -import 'prismjs/components/prism-arturo.js'; -import 'prismjs/components/prism-asciidoc.js'; -import 'prismjs/components/prism-asm6502.js'; -import 'prismjs/components/prism-asmatmel.js'; -import 'prismjs/components/prism-aspnet.js'; -import 'prismjs/components/prism-autohotkey.js'; -import 'prismjs/components/prism-autoit.js'; -import 'prismjs/components/prism-avisynth.js'; -import 'prismjs/components/prism-avro-idl.js'; -import 'prismjs/components/prism-awk.js'; -import 'prismjs/components/prism-bash.js'; -import 'prismjs/components/prism-basic.js'; -import 'prismjs/components/prism-batch.js'; -import 'prismjs/components/prism-bbcode.js'; -import 'prismjs/components/prism-bbj.js'; -import 'prismjs/components/prism-bicep.js'; -import 'prismjs/components/prism-birb.js'; -import 'prismjs/components/prism-bnf.js'; -import 'prismjs/components/prism-bqn.js'; -import 'prismjs/components/prism-brainfuck.js'; -import 'prismjs/components/prism-brightscript.js'; -import 'prismjs/components/prism-bro.js'; -import 'prismjs/components/prism-bsl.js'; -import 'prismjs/components/prism-c.js'; -import 'prismjs/components/prism-cfscript.js'; -import 'prismjs/components/prism-cil.js'; -import 'prismjs/components/prism-cilkc.js'; -import 'prismjs/components/prism-cilkcpp.js'; -import 'prismjs/components/prism-clike.js'; -import 'prismjs/components/prism-clojure.js'; -import 'prismjs/components/prism-cmake.js'; -import 'prismjs/components/prism-cobol.js'; -import 'prismjs/components/prism-coffeescript.js'; -import 'prismjs/components/prism-concurnas.js'; -import 'prismjs/components/prism-cooklang.js'; -import 'prismjs/components/prism-coq.js'; -import 'prismjs/components/prism-cpp.js'; -import 'prismjs/components/prism-csharp.js'; -import 'prismjs/components/prism-cshtml.js'; -import 'prismjs/components/prism-csp.js'; -import 'prismjs/components/prism-css-extras.js'; -import 'prismjs/components/prism-css.js'; -import 'prismjs/components/prism-csv.js'; -import 'prismjs/components/prism-cue.js'; -import 'prismjs/components/prism-cypher.js'; -import 'prismjs/components/prism-d.js'; -import 'prismjs/components/prism-dart.js'; -import 'prismjs/components/prism-dataweave.js'; -import 'prismjs/components/prism-dax.js'; -import 'prismjs/components/prism-dhall.js'; -import 'prismjs/components/prism-diff.js'; -import 'prismjs/components/prism-dns-zone-file.js'; -import 'prismjs/components/prism-docker.js'; -import 'prismjs/components/prism-dot.js'; -import 'prismjs/components/prism-ebnf.js'; -import 'prismjs/components/prism-editorconfig.js'; -import 'prismjs/components/prism-eiffel.js'; -import 'prismjs/components/prism-ejs.js'; -import 'prismjs/components/prism-elixir.js'; -import 'prismjs/components/prism-elm.js'; -import 'prismjs/components/prism-erb.js'; -import 'prismjs/components/prism-erlang.js'; -import 'prismjs/components/prism-etlua.js'; -import 'prismjs/components/prism-excel-formula.js'; -import 'prismjs/components/prism-factor.js'; -import 'prismjs/components/prism-false.js'; -import 'prismjs/components/prism-firestore-security-rules.js'; -import 'prismjs/components/prism-flow.js'; -import 'prismjs/components/prism-fortran.js'; -import 'prismjs/components/prism-fsharp.js'; -import 'prismjs/components/prism-ftl.js'; -import 'prismjs/components/prism-gap.js'; -import 'prismjs/components/prism-gcode.js'; -import 'prismjs/components/prism-gdscript.js'; -import 'prismjs/components/prism-gedcom.js'; -import 'prismjs/components/prism-gettext.js'; -import 'prismjs/components/prism-gherkin.js'; -import 'prismjs/components/prism-git.js'; -import 'prismjs/components/prism-glsl.js'; -import 'prismjs/components/prism-gml.js'; -import 'prismjs/components/prism-gn.js'; -import 'prismjs/components/prism-go-module.js'; -import 'prismjs/components/prism-go.js'; -import 'prismjs/components/prism-gradle.js'; -import 'prismjs/components/prism-graphql.js'; -import 'prismjs/components/prism-groovy.js'; -import 'prismjs/components/prism-haml.js'; -import 'prismjs/components/prism-handlebars.js'; -import 'prismjs/components/prism-haskell.js'; -import 'prismjs/components/prism-haxe.js'; -import 'prismjs/components/prism-hcl.js'; -import 'prismjs/components/prism-hlsl.js'; -import 'prismjs/components/prism-hoon.js'; -import 'prismjs/components/prism-hpkp.js'; -import 'prismjs/components/prism-hsts.js'; -import 'prismjs/components/prism-http.js'; -import 'prismjs/components/prism-ichigojam.js'; -import 'prismjs/components/prism-icon.js'; -import 'prismjs/components/prism-icu-message-format.js'; -import 'prismjs/components/prism-idris.js'; -import 'prismjs/components/prism-iecst.js'; -import 'prismjs/components/prism-ignore.js'; -import 'prismjs/components/prism-inform7.js'; -import 'prismjs/components/prism-ini.js'; -import 'prismjs/components/prism-io.js'; -import 'prismjs/components/prism-j.js'; -import 'prismjs/components/prism-java.js'; -import 'prismjs/components/prism-javadoclike.js'; -import 'prismjs/components/prism-javascript.js'; -import 'prismjs/components/prism-javastacktrace.js'; -import 'prismjs/components/prism-jexl.js'; -import 'prismjs/components/prism-jolie.js'; -import 'prismjs/components/prism-jq.js'; -import 'prismjs/components/prism-js-extras.js'; -import 'prismjs/components/prism-js-templates.js'; -import 'prismjs/components/prism-json.js'; -import 'prismjs/components/prism-json5.js'; -import 'prismjs/components/prism-jsonp.js'; -import 'prismjs/components/prism-jsstacktrace.js'; -import 'prismjs/components/prism-jsx.js'; -import 'prismjs/components/prism-julia.js'; -import 'prismjs/components/prism-keepalived.js'; -import 'prismjs/components/prism-keyman.js'; -import 'prismjs/components/prism-kotlin.js'; -import 'prismjs/components/prism-kumir.js'; -import 'prismjs/components/prism-kusto.js'; -import 'prismjs/components/prism-latex.js'; -import 'prismjs/components/prism-latte.js'; -import 'prismjs/components/prism-less.js'; -import 'prismjs/components/prism-lilypond.js'; -import 'prismjs/components/prism-linker-script.js'; -import 'prismjs/components/prism-liquid.js'; -import 'prismjs/components/prism-lisp.js'; -import 'prismjs/components/prism-livescript.js'; -import 'prismjs/components/prism-llvm.js'; -import 'prismjs/components/prism-log.js'; -import 'prismjs/components/prism-lolcode.js'; -import 'prismjs/components/prism-lua.js'; -import 'prismjs/components/prism-magma.js'; -import 'prismjs/components/prism-makefile.js'; -import 'prismjs/components/prism-markdown.js'; -import 'prismjs/components/prism-markup-templating.js'; -import 'prismjs/components/prism-markup.js'; -import 'prismjs/components/prism-mata.js'; -import 'prismjs/components/prism-matlab.js'; -import 'prismjs/components/prism-maxscript.js'; -import 'prismjs/components/prism-mel.js'; -import 'prismjs/components/prism-mermaid.js'; -import 'prismjs/components/prism-metafont.js'; -import 'prismjs/components/prism-mizar.js'; -import 'prismjs/components/prism-mongodb.js'; -import 'prismjs/components/prism-monkey.js'; -import 'prismjs/components/prism-moonscript.js'; -import 'prismjs/components/prism-n1ql.js'; -import 'prismjs/components/prism-n4js.js'; -import 'prismjs/components/prism-nand2tetris-hdl.js'; -import 'prismjs/components/prism-naniscript.js'; -import 'prismjs/components/prism-nasm.js'; -import 'prismjs/components/prism-neon.js'; -import 'prismjs/components/prism-nevod.js'; -import 'prismjs/components/prism-nginx.js'; -import 'prismjs/components/prism-nim.js'; -import 'prismjs/components/prism-nix.js'; -import 'prismjs/components/prism-nsis.js'; -import 'prismjs/components/prism-objectivec.js'; -import 'prismjs/components/prism-ocaml.js'; -import 'prismjs/components/prism-odin.js'; -import 'prismjs/components/prism-opencl.js'; -import 'prismjs/components/prism-openqasm.js'; -import 'prismjs/components/prism-oz.js'; -import 'prismjs/components/prism-parigp.js'; -import 'prismjs/components/prism-parser.js'; -import 'prismjs/components/prism-pascal.js'; -import 'prismjs/components/prism-pascaligo.js'; -import 'prismjs/components/prism-pcaxis.js'; -import 'prismjs/components/prism-peoplecode.js'; -import 'prismjs/components/prism-perl.js'; -import 'prismjs/components/prism-php-extras.js'; -import 'prismjs/components/prism-php.js'; -import 'prismjs/components/prism-phpdoc.js'; -import 'prismjs/components/prism-plant-uml.js'; -import 'prismjs/components/prism-powerquery.js'; -import 'prismjs/components/prism-powershell.js'; -import 'prismjs/components/prism-processing.js'; -import 'prismjs/components/prism-prolog.js'; -import 'prismjs/components/prism-promql.js'; -import 'prismjs/components/prism-properties.js'; -import 'prismjs/components/prism-protobuf.js'; -import 'prismjs/components/prism-psl.js'; -import 'prismjs/components/prism-pug.js'; -import 'prismjs/components/prism-puppet.js'; -import 'prismjs/components/prism-pure.js'; -import 'prismjs/components/prism-purebasic.js'; -import 'prismjs/components/prism-purescript.js'; -import 'prismjs/components/prism-python.js'; -import 'prismjs/components/prism-q.js'; -import 'prismjs/components/prism-qml.js'; -import 'prismjs/components/prism-qore.js'; -import 'prismjs/components/prism-qsharp.js'; -import 'prismjs/components/prism-r.js'; -import 'prismjs/components/prism-reason.js'; -import 'prismjs/components/prism-regex.js'; -import 'prismjs/components/prism-rego.js'; -import 'prismjs/components/prism-renpy.js'; -import 'prismjs/components/prism-rescript.js'; -import 'prismjs/components/prism-rest.js'; -import 'prismjs/components/prism-rip.js'; -import 'prismjs/components/prism-roboconf.js'; -import 'prismjs/components/prism-robotframework.js'; -import 'prismjs/components/prism-ruby.js'; -import 'prismjs/components/prism-rust.js'; -import 'prismjs/components/prism-sas.js'; -import 'prismjs/components/prism-sass.js'; -import 'prismjs/components/prism-scala.js'; -import 'prismjs/components/prism-scheme.js'; -import 'prismjs/components/prism-scss.js'; -import 'prismjs/components/prism-shell-session.js'; -import 'prismjs/components/prism-smali.js'; -import 'prismjs/components/prism-smalltalk.js'; -import 'prismjs/components/prism-smarty.js'; -import 'prismjs/components/prism-sml.js'; -import 'prismjs/components/prism-solidity.js'; -import 'prismjs/components/prism-solution-file.js'; -import 'prismjs/components/prism-soy.js'; -import 'prismjs/components/prism-splunk-spl.js'; -import 'prismjs/components/prism-sqf.js'; -import 'prismjs/components/prism-sql.js'; -import 'prismjs/components/prism-squirrel.js'; -import 'prismjs/components/prism-stan.js'; -import 'prismjs/components/prism-stata.js'; -import 'prismjs/components/prism-stylus.js'; -import 'prismjs/components/prism-supercollider.js'; -import 'prismjs/components/prism-swift.js'; -import 'prismjs/components/prism-systemd.js'; -import 'prismjs/components/prism-t4-templating.js'; -import 'prismjs/components/prism-t4-vb.js'; -import 'prismjs/components/prism-tap.js'; -import 'prismjs/components/prism-tcl.js'; -import 'prismjs/components/prism-textile.js'; -import 'prismjs/components/prism-toml.js'; -import 'prismjs/components/prism-tremor.js'; -import 'prismjs/components/prism-tsx.js'; -import 'prismjs/components/prism-tt2.js'; -import 'prismjs/components/prism-turtle.js'; -import 'prismjs/components/prism-twig.js'; -import 'prismjs/components/prism-typescript.js'; -import 'prismjs/components/prism-typoscript.js'; -import 'prismjs/components/prism-unrealscript.js'; -import 'prismjs/components/prism-uorazor.js'; -import 'prismjs/components/prism-uri.js'; -import 'prismjs/components/prism-v.js'; -import 'prismjs/components/prism-vala.js'; -import 'prismjs/components/prism-vbnet.js'; -import 'prismjs/components/prism-velocity.js'; -import 'prismjs/components/prism-verilog.js'; -import 'prismjs/components/prism-vhdl.js'; -import 'prismjs/components/prism-vim.js'; -import 'prismjs/components/prism-visual-basic.js'; -import 'prismjs/components/prism-warpscript.js'; -import 'prismjs/components/prism-wasm.js'; -import 'prismjs/components/prism-web-idl.js'; -import 'prismjs/components/prism-wgsl.js'; -import 'prismjs/components/prism-wiki.js'; -import 'prismjs/components/prism-wolfram.js'; -import 'prismjs/components/prism-wren.js'; -import 'prismjs/components/prism-xeora.js'; -import 'prismjs/components/prism-xml-doc.js'; -import 'prismjs/components/prism-xojo.js'; -import 'prismjs/components/prism-xquery.js'; -import 'prismjs/components/prism-yaml.js'; -import 'prismjs/components/prism-yang.js'; -import 'prismjs/components/prism-zig.js'; -import 'prismjs/components/prism-arduino.js'; - -// Broken: -// -// import 'prismjs/components/prism-bison.js'; -// import 'prismjs/components/prism-chaiscript.js'; -// import 'prismjs/components/prism-core.js'; -// import 'prismjs/components/prism-crystal.js'; -// import 'prismjs/components/prism-django.js'; -// import 'prismjs/components/prism-javadoc.js'; -// import 'prismjs/components/prism-jsdoc.js'; -// import 'prismjs/components/prism-plsql.js'; -// import 'prismjs/components/prism-racket.js'; -// import 'prismjs/components/prism-sparql.js'; -// import 'prismjs/components/prism-t4-cs.js'; - -import '$plugins/react-prism/ReactPrism.css'; -// using classNames .prism-dark .prism-light from ReactPrism.css - -export default function ReactPrism({ - children, -}: { - children: (ref: MutableRefObject) => ReactNode; -}) { - const codeRef = useRef(null); - - useEffect(() => { - const el = codeRef.current; - if (el) Prism.highlightElement(el); - }, []); - - return <>{children(codeRef as MutableRefObject)}; -} From 3c397acbd6fd624ad367d3c8ce4e352d4fe87848 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 11:36:23 +0100 Subject: [PATCH 15/23] test: relax text viewer renderer assertion --- src/app/components/text-viewer/TextViewer.test.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/app/components/text-viewer/TextViewer.test.tsx b/src/app/components/text-viewer/TextViewer.test.tsx index 8670cdbe3..df3a403a8 100644 --- a/src/app/components/text-viewer/TextViewer.test.tsx +++ b/src/app/components/text-viewer/TextViewer.test.tsx @@ -32,14 +32,12 @@ describe('TextViewer', () => { ); - expect(CodeHighlightRenderer).toHaveBeenCalledWith( - expect.objectContaining({ - code: 'line 1\nline 2', - language: 'txt', - allowDetect: true, - }), - {} - ); + expect(CodeHighlightRenderer).toHaveBeenCalled(); + expect(CodeHighlightRenderer.mock.calls[0]?.[0]).toMatchObject({ + code: 'line 1\nline 2', + language: 'txt', + allowDetect: true, + }); await user.click(screen.getByText('Copy All')); From 240ced593e3e5d4bbdcfe628e7e0fa7c36414dad Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 12:03:47 +0100 Subject: [PATCH 16/23] fix: add arborium language compatibility mapping --- src/app/plugins/arborium/runtime.test.ts | 97 ++++++++++++++++++++++++ src/app/plugins/arborium/runtime.ts | 94 +++++++++++++++++++++-- 2 files changed, 184 insertions(+), 7 deletions(-) diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts index 81d31e2f4..7010460f7 100644 --- a/src/app/plugins/arborium/runtime.test.ts +++ b/src/app/plugins/arborium/runtime.test.ts @@ -45,6 +45,103 @@ describe('highlightCode', () => { expect(highlight).toHaveBeenCalledWith('typescript', 'const value = 1;'); }); + it('maps jsx to tsx when Arborium supports tsx', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + availableLanguages: ['tsx', 'html'], + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '
', + language: 'jsx', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'highlighted', + html: '
', + language: 'tsx', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('tsx'); + expect(detectLanguage).not.toHaveBeenCalled(); + expect(highlight).toHaveBeenCalledWith('tsx', '
'); + }); + + it('maps markup to html when Arborium supports html', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + availableLanguages: ['tsx', 'html'], + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '

hello

', + language: 'markup', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'highlighted', + html: '

hello

', + language: 'html', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('html'); + expect(detectLanguage).not.toHaveBeenCalled(); + expect(highlight).toHaveBeenCalledWith('html', '

hello

'); + }); + + it.each(['txt', 'plaintext', 'plain', 'text', 'log', 'csv', 'makefile', 'make'])( + 'returns plain fallback for %s without loading Arborium', + async (language) => { + const loadModule = vi.fn(async () => { + throw new Error('should not load'); + }); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: 'hello, world', + language, + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'plain', + html: 'hello, world', + language, + }); + expect(loadModule).not.toHaveBeenCalled(); + } + ); + it('does not detect a language when allowDetect is false', async () => { const normalizeLanguage = vi.fn((language: string) => language); const detectLanguage = vi.fn(() => 'javascript'); diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts index 8f9da59d4..c981233c7 100644 --- a/src/app/plugins/arborium/runtime.ts +++ b/src/app/plugins/arborium/runtime.ts @@ -1,4 +1,24 @@ type ArboriumModule = typeof import('@arborium/arborium'); +type ArboriumModuleWithAvailability = ArboriumModule & { + availableLanguages?: string[]; + isLanguageAvailable?: (language: string) => boolean | Promise; +}; + +const PLAIN_LANGUAGES = new Set([ + 'txt', + 'plaintext', + 'plain', + 'text', + 'log', + 'csv', + 'makefile', + 'make', +]); + +const LANGUAGE_COMPATIBILITY: Record = { + jsx: 'tsx', + markup: 'html', +}; export interface HighlightCodeInput { code: string; @@ -37,6 +57,35 @@ function plainResult(code: string, language?: string): HighlightResult { return result; } +function resolveCompatibleLanguage(language: string): string | null { + const lowerLanguage = language.toLowerCase(); + + if (PLAIN_LANGUAGES.has(lowerLanguage)) { + return null; + } + + return LANGUAGE_COMPATIBILITY[lowerLanguage] ?? language; +} + +async function isLanguageAvailable( + arborium: ArboriumModuleWithAvailability, + language: string +): Promise { + if (Array.isArray(arborium.availableLanguages)) { + return arborium.availableLanguages.includes(language); + } + + if (typeof arborium.isLanguageAvailable === 'function') { + try { + return await arborium.isLanguageAvailable(language); + } catch { + return false; + } + } + + return true; +} + async function loadArborium( loadModule?: () => Promise ): Promise { @@ -60,6 +109,43 @@ export async function highlightCode( deps?: HighlightCodeDeps ): Promise { const { loadModule } = deps ?? {}; + if (language) { + const compatibleLanguage = resolveCompatibleLanguage(language); + if (!compatibleLanguage) { + return plainResult(code, language); + } + + const arborium = await loadArborium(loadModule); + if (!arborium) { + return plainResult(code, language); + } + + let resolvedLanguage: string; + try { + resolvedLanguage = arborium.normalizeLanguage(compatibleLanguage); + } catch { + return plainResult(code, language); + } + + if (!(await isLanguageAvailable(arborium, resolvedLanguage))) { + return plainResult(code, language); + } + + try { + const html = await arborium.highlight(resolvedLanguage, code); + if (html === escapeHtml(code)) { + return plainResult(code, resolvedLanguage); + } + return { + mode: 'highlighted', + html, + language: resolvedLanguage, + }; + } catch { + return plainResult(code, resolvedLanguage); + } + } + const arborium = await loadArborium(loadModule); if (!arborium) { return plainResult(code, language ?? undefined); @@ -67,13 +153,7 @@ export async function highlightCode( let resolvedLanguage: string | null = null; - if (language) { - try { - resolvedLanguage = arborium.normalizeLanguage(language); - } catch { - return plainResult(code, language ?? undefined); - } - } else if (allowDetect) { + if (allowDetect) { try { const detectedLanguage = arborium.detectLanguage(code); if (detectedLanguage) { From 9a15cafc025d003ba8422d4a1d4d4c97a8a8a8a3 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 12:37:56 +0100 Subject: [PATCH 17/23] fix: trust arborium availability for unsupported labels --- src/app/plugins/arborium/runtime.test.ts | 28 +++++++++++++++++++---- src/app/plugins/arborium/runtime.ts | 29 +++++------------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts index 7010460f7..3c6d89ef9 100644 --- a/src/app/plugins/arborium/runtime.test.ts +++ b/src/app/plugins/arborium/runtime.test.ts @@ -116,11 +116,29 @@ describe('highlightCode', () => { }); it.each(['txt', 'plaintext', 'plain', 'text', 'log', 'csv', 'makefile', 'make'])( - 'returns plain fallback for %s without loading Arborium', + 'returns plain fallback for %s when Arborium reports it unavailable', async (language) => { - const loadModule = vi.fn(async () => { - throw new Error('should not load'); + const normalizeLanguage = vi.fn((nextLanguage: string) => { + if (nextLanguage === 'txt' || nextLanguage === 'plaintext' || nextLanguage === 'plain') { + return 'text'; + } + if (nextLanguage === 'makefile') { + return 'make'; + } + return nextLanguage; }); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn(async () => '
');
+      const isLanguageAvailable = vi.fn(
+        async (nextLanguage: string) => !['text', 'log', 'csv', 'make'].includes(nextLanguage)
+      );
+      const module = {
+        normalizeLanguage,
+        detectLanguage,
+        highlight,
+        isLanguageAvailable,
+      } as unknown as ArboriumModule;
+      const loadModule = vi.fn(async () => module);
 
       const { highlightCode } = await import('.');
 
@@ -138,7 +156,9 @@ describe('highlightCode', () => {
         html: 'hello, world',
         language,
       });
-      expect(loadModule).not.toHaveBeenCalled();
+      expect(loadModule).toHaveBeenCalledOnce();
+      expect(normalizeLanguage).toHaveBeenCalledWith(language);
+      expect(highlight).not.toHaveBeenCalled();
     }
   );
 
diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts
index c981233c7..30d22031d 100644
--- a/src/app/plugins/arborium/runtime.ts
+++ b/src/app/plugins/arborium/runtime.ts
@@ -4,17 +4,6 @@ type ArboriumModuleWithAvailability = ArboriumModule & {
   isLanguageAvailable?: (language: string) => boolean | Promise;
 };
 
-const PLAIN_LANGUAGES = new Set([
-  'txt',
-  'plaintext',
-  'plain',
-  'text',
-  'log',
-  'csv',
-  'makefile',
-  'make',
-]);
-
 const LANGUAGE_COMPATIBILITY: Record = {
   jsx: 'tsx',
   markup: 'html',
@@ -57,14 +46,8 @@ function plainResult(code: string, language?: string): HighlightResult {
   return result;
 }
 
-function resolveCompatibleLanguage(language: string): string | null {
-  const lowerLanguage = language.toLowerCase();
-
-  if (PLAIN_LANGUAGES.has(lowerLanguage)) {
-    return null;
-  }
-
-  return LANGUAGE_COMPATIBILITY[lowerLanguage] ?? language;
+function resolveCompatibleLanguage(language: string): string {
+  return LANGUAGE_COMPATIBILITY[language.toLowerCase()] ?? language;
 }
 
 async function isLanguageAvailable(
@@ -111,10 +94,6 @@ export async function highlightCode(
   const { loadModule } = deps ?? {};
   if (language) {
     const compatibleLanguage = resolveCompatibleLanguage(language);
-    if (!compatibleLanguage) {
-      return plainResult(code, language);
-    }
-
     const arborium = await loadArborium(loadModule);
     if (!arborium) {
       return plainResult(code, language);
@@ -168,6 +147,10 @@ export async function highlightCode(
     return plainResult(code, language ?? undefined);
   }
 
+  if (!(await isLanguageAvailable(arborium, resolvedLanguage))) {
+    return plainResult(code, language ?? undefined);
+  }
+
   try {
     const html = await arborium.highlight(resolvedLanguage, code);
     if (html === escapeHtml(code)) {

From 26755262cd6440cb1074acf5a41b25683a886e5e Mon Sep 17 00:00:00 2001
From: hazre 
Date: Sat, 28 Mar 2026 20:02:00 +0100
Subject: [PATCH 18/23] feat: add reusable settings selectors and code block
 themes

---
 .../RoomNotificationSwitcher.test.tsx         |  81 +++++
 .../components/RoomNotificationSwitcher.tsx   | 138 +++----
 .../CodeHighlightRenderer.css.ts              |   8 +
 .../CodeHighlightRenderer.test.tsx            |   3 +
 .../code-highlight/CodeHighlightRenderer.tsx  |  10 +-
 .../SettingMenuSelector.test.tsx              |  71 ++++
 .../SettingMenuSelector.tsx                   | 202 +++++++++++
 .../components/setting-menu-selector/index.ts |   1 +
 .../components/text-viewer/TextViewer.css.ts  |   1 +
 .../text-viewer/TextViewer.test.tsx           |   2 +
 .../settings/cosmetics/Themes.test.tsx        | 151 ++++++++
 .../features/settings/cosmetics/Themes.tsx    | 338 ++++++++----------
 .../settings/notifications/AllMessages.tsx    |  30 +-
 .../notifications/KeywordMessages.tsx         |  39 +-
 .../NotificationModeSwitcher.tsx              | 117 ------
 .../notifications/SpecialMessages.tsx         |  30 +-
 .../notificationModeOptions.test.ts           |  15 +
 .../notifications/notificationModeOptions.ts  |   8 +
 src/app/pages/ThemeManager.test.tsx           |  12 +-
 .../arborium/ArboriumThemeBridge.test.tsx     |  72 +++-
 .../plugins/arborium/ArboriumThemeBridge.tsx  |  33 +-
 src/app/plugins/arborium/index.ts             |   7 +
 src/app/plugins/arborium/themes.test.ts       |  58 +++
 src/app/plugins/arborium/themes.ts            |  67 ++++
 .../plugins/react-custom-html-parser.test.tsx |   2 +
 src/app/state/settings.ts                     |   4 +
 src/app/styles/CustomHtml.css.ts              |   3 +
 src/app/utils/settingsSync.test.ts            |   9 +-
 28 files changed, 1045 insertions(+), 467 deletions(-)
 create mode 100644 src/app/components/RoomNotificationSwitcher.test.tsx
 create mode 100644 src/app/components/code-highlight/CodeHighlightRenderer.css.ts
 create mode 100644 src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx
 create mode 100644 src/app/components/setting-menu-selector/SettingMenuSelector.tsx
 create mode 100644 src/app/components/setting-menu-selector/index.ts
 create mode 100644 src/app/features/settings/cosmetics/Themes.test.tsx
 delete mode 100644 src/app/features/settings/notifications/NotificationModeSwitcher.tsx
 create mode 100644 src/app/features/settings/notifications/notificationModeOptions.test.ts
 create mode 100644 src/app/features/settings/notifications/notificationModeOptions.ts
 create mode 100644 src/app/plugins/arborium/themes.test.ts
 create mode 100644 src/app/plugins/arborium/themes.ts

diff --git a/src/app/components/RoomNotificationSwitcher.test.tsx b/src/app/components/RoomNotificationSwitcher.test.tsx
new file mode 100644
index 000000000..ef4938210
--- /dev/null
+++ b/src/app/components/RoomNotificationSwitcher.test.tsx
@@ -0,0 +1,81 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { RoomNotificationMode } from '$hooks/useRoomsNotificationPreferences';
+
+import { RoomNotificationModeSwitcher } from './RoomNotificationSwitcher';
+
+const { mockSetMode, modeStateStatus } = vi.hoisted(() => ({
+  mockSetMode: vi.fn(),
+  modeStateStatus: { current: 'idle' as 'idle' | 'loading' },
+}));
+
+vi.mock('$hooks/useRoomsNotificationPreferences', async () => {
+  const actual = await vi.importActual(
+    '$hooks/useRoomsNotificationPreferences'
+  );
+
+  return {
+    ...actual,
+    useSetRoomNotificationPreference: () => ({
+      modeState: { status: modeStateStatus.current },
+      setMode: mockSetMode,
+    }),
+  };
+});
+
+afterEach(() => {
+  mockSetMode.mockClear();
+});
+
+describe('RoomNotificationModeSwitcher', () => {
+  it('renders the shared selector trigger and real option content', () => {
+    modeStateStatus.current = 'idle';
+
+    render(
+      
+        {(openMenu, opened, changing) => (
+          
+        )}
+      
+    );
+
+    expect(screen.getByRole('button', { name: 'closed idle' })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'closed idle' }));
+
+    expect(screen.getByRole('button', { name: 'open idle' })).toBeInTheDocument();
+    expect(screen.getByText('Follows your global notification rules')).toBeInTheDocument();
+    fireEvent.click(screen.getByRole('button', { name: 'Mention & Keywords' }));
+
+    expect(mockSetMode).toHaveBeenCalledOnce();
+    expect(mockSetMode).toHaveBeenCalledWith(
+      RoomNotificationMode.SpecialMessages,
+      RoomNotificationMode.Unset
+    );
+  });
+
+  it('disables interaction while the room mode is changing', () => {
+    modeStateStatus.current = 'loading';
+
+    render(
+      
+        {(openMenu, opened, changing) => (
+          
+        )}
+      
+    );
+
+    const trigger = screen.getByRole('button', { name: 'closed changing' });
+
+    expect(trigger).toBeDisabled();
+    fireEvent.click(trigger);
+    expect(screen.queryByRole('button', { name: 'open changing' })).not.toBeInTheDocument();
+
+    expect(mockSetMode).not.toHaveBeenCalled();
+  });
+});
diff --git a/src/app/components/RoomNotificationSwitcher.tsx b/src/app/components/RoomNotificationSwitcher.tsx
index 0b5c9e383..2bab6de16 100644
--- a/src/app/components/RoomNotificationSwitcher.tsx
+++ b/src/app/components/RoomNotificationSwitcher.tsx
@@ -1,7 +1,6 @@
-import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text, toRem } from 'folds';
-import { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import { stopPropagation } from '$utils/keyboard';
+import { Box, Icon, Text } from 'folds';
+import { type MouseEventHandler, ReactNode } from 'react';
+import { SettingMenuSelector, type SettingMenuOption } from '$components/setting-menu-selector';
 import {
   getRoomNotificationModeIcon,
   RoomNotificationMode,
@@ -9,27 +8,24 @@ import {
 } from '$hooks/useRoomsNotificationPreferences';
 import { AsyncStatus } from '$hooks/useAsyncCallback';
 
-const useRoomNotificationModes = (): RoomNotificationMode[] =>
-  useMemo(
-    () => [
-      RoomNotificationMode.Unset,
-      RoomNotificationMode.AllMessages,
-      RoomNotificationMode.SpecialMessages,
-      RoomNotificationMode.Mute,
-    ],
-    []
-  );
+const ROOM_NOTIFICATION_MODE_LABELS: Record = {
+  [RoomNotificationMode.Unset]: 'Default',
+  [RoomNotificationMode.AllMessages]: 'All Messages',
+  [RoomNotificationMode.SpecialMessages]: 'Mention & Keywords',
+  [RoomNotificationMode.Mute]: 'Mute',
+};
 
-const useRoomNotificationModeStr = (): Record =>
-  useMemo(
-    () => ({
-      [RoomNotificationMode.Unset]: 'Default',
-      [RoomNotificationMode.AllMessages]: 'All Messages',
-      [RoomNotificationMode.SpecialMessages]: 'Mention & Keywords',
-      [RoomNotificationMode.Mute]: 'Mute',
-    }),
-    []
-  );
+const ROOM_NOTIFICATION_MODE_OPTIONS: SettingMenuOption[] = [
+  RoomNotificationMode.Unset,
+  RoomNotificationMode.AllMessages,
+  RoomNotificationMode.SpecialMessages,
+  RoomNotificationMode.Mute,
+].map((mode) => ({
+  value: mode,
+  label: ROOM_NOTIFICATION_MODE_LABELS[mode],
+  description:
+    mode === RoomNotificationMode.Unset ? 'Follows your global notification rules' : undefined,
+}));
 
 type NotificationModeSwitcherProps = {
   roomId: string;
@@ -45,84 +41,36 @@ export function RoomNotificationModeSwitcher({
   value = RoomNotificationMode.Unset,
   children,
 }: NotificationModeSwitcherProps) {
-  const modes = useRoomNotificationModes();
-  const modeToStr = useRoomNotificationModeStr();
-
   const { modeState, setMode } = useSetRoomNotificationPreference(roomId);
   const changing = modeState.status === AsyncStatus.Loading;
 
-  const [menuCords, setMenuCords] = useState();
-
-  const handleOpenMenu: MouseEventHandler = (evt) => {
-    setMenuCords(evt.currentTarget.getBoundingClientRect());
-  };
-
-  const handleClose = () => {
-    setMenuCords(undefined);
-  };
-
-  const handleSelect = (mode: RoomNotificationMode) => {
-    if (changing) return;
-    setMode(mode, value);
-    handleClose();
-  };
-
   return (
-     setMode(mode, value)}
+      loading={changing}
       offset={5}
       position="Right"
       align="Start"
-      content={
-        
-              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
-            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
-            escapeDeactivates: stopPropagation,
-          }}
+      renderTrigger={({ openMenu, opened }) => children(openMenu, opened, changing)}
+      renderOption={({ option, selected }) => (
+        
-          
-            
-              {modes.map((mode) => (
-                 handleSelect(mode)}
-                  before={
-                    
-                  }
-                >
-                  
-                    
-                      {mode === value ? {modeToStr[mode]} : modeToStr[mode]}
-                    
-                    {mode === RoomNotificationMode.Unset && (
-                      
-                        Follows your global notification rules
-                      
-                    )}
-                  
-                
-              ))}
-            
-          
-        
-      }
-    >
-      {children(handleOpenMenu, !!menuCords, changing)}
-    
+          
+          
+            {selected ? {option.label} : option.label}
+            {option.description && (
+              
+                {option.description}
+              
+            )}
+          
+        
+      )}
+    />
   );
 }
diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.css.ts b/src/app/components/code-highlight/CodeHighlightRenderer.css.ts
new file mode 100644
index 000000000..7f7aabf9d
--- /dev/null
+++ b/src/app/components/code-highlight/CodeHighlightRenderer.css.ts
@@ -0,0 +1,8 @@
+import { style } from '@vanilla-extract/css';
+
+export const CodeHighlightCode = style({
+  display: 'block',
+  whiteSpace: 'inherit',
+  overflowWrap: 'inherit',
+  wordBreak: 'inherit',
+});
diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx
index 462bdccdf..6a333b662 100644
--- a/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx
+++ b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx
@@ -3,6 +3,7 @@ import { flushSync } from 'react-dom';
 import { createRoot } from 'react-dom/client';
 import { afterEach, describe, expect, it, vi } from 'vitest';
 import { CodeHighlightRenderer } from '.';
+import * as css from './CodeHighlightRenderer.css';
 
 const { highlightCode, useArboriumThemeStatus } = vi.hoisted(() => ({
   highlightCode: vi.fn(),
@@ -45,6 +46,7 @@ describe('CodeHighlightRenderer', () => {
 
     const code = container.querySelector('code');
     expect(code).toHaveClass('code');
+    expect(code).toHaveClass(css.CodeHighlightCode);
 
     await waitFor(() => {
       expect(code?.innerHTML).toContain('const');
@@ -76,6 +78,7 @@ describe('CodeHighlightRenderer', () => {
       expect(code).toHaveTextContent('const value = 1;');
     });
 
+    expect(code).toHaveClass(css.CodeHighlightCode);
     expect(code?.innerHTML).toBe('const value = 1;');
   });
 
diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx
index e7c24d9c0..3e66e74c5 100644
--- a/src/app/components/code-highlight/CodeHighlightRenderer.tsx
+++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx
@@ -1,6 +1,7 @@
 import { useEffect, useState } from 'react';
 
 import { highlightCode, type HighlightResult, useArboriumThemeStatus } from '$plugins/arborium';
+import * as css from './CodeHighlightRenderer.css';
 
 type CodeHighlightRendererProps = {
   code: string;
@@ -66,11 +67,14 @@ export function CodeHighlightRenderer({
   }, [code, language, allowDetect, requestKey]);
 
   const currentResult = state.key === requestKey ? state.result : createPlainResult(code, language);
+  const codeClassName = [css.CodeHighlightCode, className].filter(Boolean).join(' ');
 
   if (!ready || currentResult.mode === 'plain') {
-    return {code};
+    return {code};
   }
 
-  /* eslint-disable-next-line react/no-danger */
-  return ;
+  return (
+    // eslint-disable-next-line react/no-danger
+    
+  );
 }
diff --git a/src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx b/src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx
new file mode 100644
index 000000000..60fd7453b
--- /dev/null
+++ b/src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { SettingMenuSelector, type SettingMenuOption } from './SettingMenuSelector';
+
+describe('SettingMenuSelector', () => {
+  it('renders the selected label, opens the menu, and selects an option', () => {
+    const onSelect = vi.fn();
+    const options: SettingMenuOption<'light' | 'dark'>[] = [
+      { value: 'light', label: 'Light', description: 'Plain theme' },
+      { value: 'dark', label: 'Dark', description: 'High contrast' },
+    ];
+
+    render();
+
+    expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Dark' }));
+
+    expect(screen.getByText('Plain theme')).toBeInTheDocument();
+    expect(screen.getByText('High contrast')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByText('Light'));
+
+    expect(onSelect).toHaveBeenCalledOnce();
+    expect(onSelect).toHaveBeenCalledWith('light');
+    expect(screen.queryByText('Plain theme')).not.toBeInTheDocument();
+  });
+
+  it('disables the trigger while loading', () => {
+    const onSelect = vi.fn();
+
+    render(
+      
+    );
+
+    expect(screen.getByRole('button', { name: 'Dark' })).toBeDisabled();
+  });
+
+  it('supports custom trigger and option rendering', () => {
+    const onSelect = vi.fn();
+    const options: SettingMenuOption<'one' | 'two'>[] = [
+      { value: 'one', label: 'One' },
+      { value: 'two', label: 'Two' },
+    ];
+
+    render(
+       (
+          
+        )}
+        renderOption={({ option, selected }) => {option.label}}
+      />
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: 'Pick One' }));
+    fireEvent.click(screen.getByText('Two'));
+
+    expect(onSelect).toHaveBeenCalledWith('two');
+  });
+});
diff --git a/src/app/components/setting-menu-selector/SettingMenuSelector.tsx b/src/app/components/setting-menu-selector/SettingMenuSelector.tsx
new file mode 100644
index 000000000..3e39dd243
--- /dev/null
+++ b/src/app/components/setting-menu-selector/SettingMenuSelector.tsx
@@ -0,0 +1,202 @@
+import FocusTrap from 'focus-trap-react';
+import {
+  Box,
+  Button,
+  config,
+  Icon,
+  Icons,
+  Menu,
+  MenuItem,
+  PopOut,
+  RectCords,
+  Spinner,
+  Text,
+} from 'folds';
+import {
+  type ComponentPropsWithoutRef,
+  type MouseEventHandler,
+  type ReactNode,
+  useState,
+} from 'react';
+
+import { stopPropagation } from '$utils/keyboard';
+
+export type SettingMenuOption = {
+  value: T;
+  label: string;
+  description?: string;
+  icon?: ReactNode;
+  disabled?: boolean;
+};
+
+type MenuPosition = ComponentPropsWithoutRef['position'];
+type MenuAlign = ComponentPropsWithoutRef['align'];
+
+export type SettingMenuRenderTriggerArgs = {
+  value: T;
+  selectedOption: SettingMenuOption;
+  opened: boolean;
+  loading: boolean;
+  disabled: boolean;
+  openMenu: MouseEventHandler;
+};
+
+export type SettingMenuRenderOptionArgs = {
+  option: SettingMenuOption;
+  selected: boolean;
+  select: () => void;
+};
+
+export type SettingMenuSelectorProps = {
+  value: T;
+  options: SettingMenuOption[];
+  onSelect: (value: T) => void;
+  disabled?: boolean;
+  loading?: boolean;
+  position?: MenuPosition;
+  align?: MenuAlign;
+  offset?: number;
+  renderTrigger?: (args: SettingMenuRenderTriggerArgs) => ReactNode;
+  renderOption?: (args: SettingMenuRenderOptionArgs) => ReactNode;
+};
+
+export function SettingMenuSelector({
+  value,
+  options,
+  onSelect,
+  disabled = false,
+  loading = false,
+  position = 'Bottom',
+  align = 'End',
+  offset = 5,
+  renderTrigger,
+  renderOption,
+}: SettingMenuSelectorProps) {
+  const [menuCords, setMenuCords] = useState();
+  const selectedOption = options.find((option) => option.value === value) ?? options[0];
+  const selectedLabel = selectedOption?.label ?? value;
+  const isDisabled = disabled || loading;
+
+  const handleOpenMenu: MouseEventHandler = (evt) => {
+    if (isDisabled) return;
+    setMenuCords(evt.currentTarget.getBoundingClientRect());
+  };
+
+  const handleCloseMenu = () => {
+    setMenuCords(undefined);
+  };
+
+  const handleSelect = (nextValue: T) => {
+    handleCloseMenu();
+    onSelect(nextValue);
+  };
+
+  const trigger = renderTrigger ? (
+    renderTrigger({
+      value,
+      selectedOption: selectedOption ?? { value, label: selectedLabel },
+      opened: !!menuCords,
+      loading,
+      disabled: isDisabled,
+      openMenu: handleOpenMenu,
+    })
+  ) : (
+    
+  );
+
+  return (
+    <>
+      {trigger}
+       document.body,
+              onDeactivate: handleCloseMenu,
+              clickOutsideDeactivates: true,
+              isKeyForward: (evt: KeyboardEvent) =>
+                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+              isKeyBackward: (evt: KeyboardEvent) =>
+                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+              escapeDeactivates: stopPropagation,
+            }}
+          >
+            
+              
+                {options.map((option) => {
+                  const selected = option.value === value;
+                  const select = () => {
+                    if (option.disabled) return;
+                    handleSelect(option.value);
+                  };
+
+                  if (renderOption) {
+                    return (
+                      
+                        {renderOption({ option, selected, select })}
+                      
+                    );
+                  }
+
+                  return (
+                    
+                      
+                        
+                          {option.label}
+                          {option.description && (
+                            
+                              {option.description}
+                            
+                          )}
+                        
+                      
+                    
+                  );
+                })}
+              
+            
+          
+        }
+      />
+    
+  );
+}
diff --git a/src/app/components/setting-menu-selector/index.ts b/src/app/components/setting-menu-selector/index.ts
new file mode 100644
index 000000000..6aaeff0de
--- /dev/null
+++ b/src/app/components/setting-menu-selector/index.ts
@@ -0,0 +1 @@
+export * from './SettingMenuSelector';
diff --git a/src/app/components/text-viewer/TextViewer.css.ts b/src/app/components/text-viewer/TextViewer.css.ts
index 83ee6058b..23c779d59 100644
--- a/src/app/components/text-viewer/TextViewer.css.ts
+++ b/src/app/components/text-viewer/TextViewer.css.ts
@@ -32,6 +32,7 @@ export const TextViewerPre = style([
   DefaultReset,
   {
     whiteSpace: 'pre-wrap',
+    overflowWrap: 'anywhere',
     wordBreak: 'break-word',
   },
 ]);
diff --git a/src/app/components/text-viewer/TextViewer.test.tsx b/src/app/components/text-viewer/TextViewer.test.tsx
index df3a403a8..2112b0f14 100644
--- a/src/app/components/text-viewer/TextViewer.test.tsx
+++ b/src/app/components/text-viewer/TextViewer.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { afterEach, describe, expect, it, vi } from 'vitest';
 import { TextViewer } from './TextViewer';
+import * as css from './TextViewer.css';
 
 const { copyToClipboard, CodeHighlightRenderer } = vi.hoisted(() => ({
   copyToClipboard: vi.fn(),
@@ -43,5 +44,6 @@ describe('TextViewer', () => {
 
     expect(copyToClipboard).toHaveBeenCalledWith('line 1\nline 2');
     expect(screen.getByTestId('highlight')).toHaveTextContent('line 1 line 2');
+    expect(screen.getByTestId('highlight').closest('pre')).toHaveClass(css.TextViewerPre);
   });
 });
diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx
new file mode 100644
index 000000000..1aee22d02
--- /dev/null
+++ b/src/app/features/settings/cosmetics/Themes.test.tsx
@@ -0,0 +1,151 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { Appearance } from './Themes';
+
+type SettingsShape = {
+  themeId?: string;
+  useSystemTheme: boolean;
+  lightThemeId?: string;
+  darkThemeId?: string;
+  arboriumLightTheme?: string;
+  arboriumDarkTheme?: string;
+  saturationLevel: number;
+  underlineLinks: boolean;
+  reducedMotion: boolean;
+  autoplayGifs: boolean;
+  autoplayStickers: boolean;
+  autoplayEmojis: boolean;
+  twitterEmoji: boolean;
+  showEasterEggs: boolean;
+  subspaceHierarchyLimit: number;
+  pageZoom: number;
+};
+
+let currentSettings: SettingsShape;
+const setters = new Map>();
+
+const getSetter = (key: string) => {
+  if (!setters.has(key)) {
+    setters.set(key, vi.fn());
+  }
+
+  return setters.get(key)!;
+};
+
+vi.mock('$state/hooks/settings', () => ({
+  useSetting: (_atom: unknown, key: keyof SettingsShape) => [currentSettings[key], getSetter(key)],
+}));
+
+vi.mock('$hooks/useTheme', async () => {
+  const actual = await vi.importActual('$hooks/useTheme');
+
+  return {
+    ...actual,
+    useSystemThemeKind: () => actual.ThemeKind.Light,
+  };
+});
+
+beforeEach(() => {
+  setters.clear();
+  currentSettings = {
+    themeId: 'silver-theme',
+    useSystemTheme: true,
+    lightThemeId: 'cinny-light-theme',
+    darkThemeId: 'black-theme',
+    arboriumLightTheme: 'github-light',
+    arboriumDarkTheme: 'one-dark',
+    saturationLevel: 100,
+    underlineLinks: false,
+    reducedMotion: false,
+    autoplayGifs: true,
+    autoplayStickers: true,
+    autoplayEmojis: true,
+    twitterEmoji: true,
+    showEasterEggs: true,
+    subspaceHierarchyLimit: 3,
+    pageZoom: 100,
+  };
+});
+
+afterEach(() => {
+  vi.clearAllMocks();
+});
+
+const clickLatestButton = (name: string) => {
+  const nodes = screen.getAllByText(name);
+  fireEvent.click(nodes.at(-1)!);
+};
+
+describe('Appearance settings', () => {
+  it('renders shared selector-backed theme controls and Arborium code block selectors', () => {
+    render();
+
+    expect(screen.getByRole('button', { name: 'Silver' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Cinny Light' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Black' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'One Dark' })).toBeInTheDocument();
+  });
+
+  it('updates the manual and Arborium theme settings when selections change', () => {
+    currentSettings = {
+      ...currentSettings,
+      useSystemTheme: false,
+    };
+
+    render();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Silver' }));
+    clickLatestButton('Dark');
+
+    fireEvent.click(screen.getByRole('button', { name: 'GitHub Light' }));
+    clickLatestButton('Ayu Light');
+
+    fireEvent.click(screen.getByRole('button', { name: 'One Dark' }));
+    clickLatestButton('Dracula');
+
+    expect(getSetter('themeId')).toHaveBeenCalledWith('dark-theme');
+    expect(getSetter('arboriumLightTheme')).toHaveBeenCalledWith('ayu-light');
+    expect(getSetter('arboriumDarkTheme')).toHaveBeenCalledWith('dracula');
+  });
+
+  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('falls back to light theme ids when the stored app theme ids are invalid', () => {
+    currentSettings = {
+      ...currentSettings,
+      useSystemTheme: false,
+      themeId: 'not-a-theme',
+    };
+
+    render();
+
+    expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
+  });
+
+  it('falls back to the default light and dark theme ids for invalid system theme values', () => {
+    currentSettings = {
+      ...currentSettings,
+      themeId: 'silver-theme',
+      lightThemeId: 'not-a-light-theme',
+      darkThemeId: 'not-a-dark-theme',
+    };
+
+    render();
+
+    expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
+  });
+});
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index 0ec660aa0..8c3966c28 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -1,27 +1,16 @@
-import { ChangeEventHandler, KeyboardEventHandler, MouseEventHandler, useState } from 'react';
-import {
-  as,
-  Box,
-  Button,
-  Chip,
-  config,
-  Icon,
-  Icons,
-  Input,
-  Menu,
-  MenuItem,
-  PopOut,
-  RectCords,
-  Switch,
-  Text,
-  toRem,
-} from 'folds';
+import { ChangeEventHandler, KeyboardEventHandler, type MouseEventHandler, useState } from 'react';
+import { Box, Chip, config, Icon, Icons, Input, Switch, Text, toRem } from 'folds';
 import { isKeyHotkey } from 'is-hotkey';
-import FocusTrap from 'focus-trap-react';
+
+import { SettingMenuSelector } from '$components/setting-menu-selector';
 import { SequenceCard } from '$components/sequence-card';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
 import { SettingTile } from '$components/setting-tile';
+import {
+  DEFAULT_ARBORIUM_DARK_THEME,
+  DEFAULT_ARBORIUM_LIGHT_THEME,
+  getArboriumThemeLabel,
+  getArboriumThemeOptions,
+} from '$plugins/arborium';
 import {
   DarkTheme,
   LightTheme,
@@ -31,93 +20,59 @@ import {
   useThemeNames,
   useThemes,
 } from '$hooks/useTheme';
-import { stopPropagation } from '$utils/keyboard';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
 import { SequenceCardStyle } from '$features/settings/styles.css';
 
-type ThemeSelectorProps = {
-  themeNames: Record;
-  themes: Theme[];
-  selected: Theme;
-  onSelect: (theme: Theme) => void;
-};
-export const ThemeSelector = as<'div', ThemeSelectorProps>(
-  ({ themeNames, themes, selected, onSelect, ...props }, ref) => (
-    
-      
-        {themes.map((theme) => (
-           onSelect(theme)}
-          >
-            {themeNames[theme.id] ?? theme.id}
-          
-        ))}
-      
-    
-  )
-);
+function makeThemeOptions(themes: Theme[], themeNames: Record) {
+  return themes.map((theme) => ({
+    value: theme.id,
+    label: themeNames[theme.id] ?? theme.id,
+  }));
+}
+
+function ThemeTrigger({
+  selectedLabel,
+  onClick,
+  active,
+  disabled,
+}: {
+  selectedLabel: string;
+  onClick: MouseEventHandler;
+  active: boolean;
+  disabled?: boolean;
+}) {
+  return (
+    }
+      onClick={onClick}
+      disabled={disabled}
+    >
+      {selectedLabel}
+    
+  );
+}
 
 function SelectTheme({ disabled }: Readonly<{ disabled?: boolean }>) {
   const themes = useThemes();
   const themeNames = useThemeNames();
   const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
-  const [menuCords, setMenuCords] = useState();
-  const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
-
-  const handleThemeMenu: MouseEventHandler = (evt) => {
-    setMenuCords(evt.currentTarget.getBoundingClientRect());
-  };
 
-  const handleThemeSelect = (theme: Theme) => {
-    setThemeId(theme.id);
-    setMenuCords(undefined);
-  };
+  const themeOptions = makeThemeOptions(themes, themeNames);
+  const selectedThemeId =
+    themeOptions.find((theme) => theme.value === themeId)?.value ?? LightTheme.id;
 
   return (
-    <>
-      
-       setMenuCords(undefined),
-              clickOutsideDeactivates: true,
-              isKeyForward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
-              isKeyBackward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
-              escapeDeactivates: stopPropagation,
-            }}
-          >
-            
-          
-        }
-      />
-    
+    
   );
 }
 
@@ -130,118 +85,114 @@ function SystemThemePreferences() {
 
   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 selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
-  const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme;
-
-  const [ltCords, setLTCords] = useState();
-  const [dtCords, setDTCords] = useState();
-
-  const handleLightThemeMenu: MouseEventHandler = (evt) => {
-    setLTCords(evt.currentTarget.getBoundingClientRect());
-  };
-  const handleDarkThemeMenu: MouseEventHandler = (evt) => {
-    setDTCords(evt.currentTarget.getBoundingClientRect());
-  };
-
-  const handleLightThemeSelect = (theme: Theme) => {
-    setLightThemeId(theme.id);
-    setLTCords(undefined);
-  };
-
-  const handleDarkThemeSelect = (theme: Theme) => {
-    setDarkThemeId(theme.id);
-    setDTCords(undefined);
-  };
+  const selectedLightThemeId =
+    lightThemeOptions.find((theme) => theme.value === lightThemeId)?.value ?? LightTheme.id;
+  const selectedDarkThemeId =
+    darkThemeOptions.find((theme) => theme.value === darkThemeId)?.value ?? DarkTheme.id;
 
   return (
     
       }
-            onClick={handleLightThemeMenu}
-          >
-            {themeNames[selectedLightTheme.id] ?? selectedLightTheme.id}
-          
-        }
-      />
-       setLTCords(undefined),
-              clickOutsideDeactivates: true,
-              isKeyForward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
-              isKeyBackward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
-              escapeDeactivates: stopPropagation,
-            }}
-          >
-            
-          
+           (
+              
+            )}
+          />
         }
       />
       }
-            onClick={handleDarkThemeMenu}
-          >
-            {themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id}
-          
-        }
-      />
-       setDTCords(undefined),
-              clickOutsideDeactivates: true,
-              isKeyForward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
-              isKeyBackward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
-              escapeDeactivates: stopPropagation,
-            }}
-          >
-            
-          
+           (
+              
+            )}
+          />
         }
       />
     
   );
 }
 
+function CodeBlockThemePreferences() {
+  const [arboriumLightTheme, setArboriumLightTheme] = useSetting(
+    settingsAtom,
+    'arboriumLightTheme'
+  );
+  const [arboriumDarkTheme, setArboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme');
+
+  const arboriumLightThemes = getArboriumThemeOptions('light');
+  const arboriumDarkThemes = getArboriumThemeOptions('dark');
+
+  const selectedArboriumLightTheme =
+    arboriumLightThemes.find((theme) => theme.id === arboriumLightTheme)?.id ??
+    DEFAULT_ARBORIUM_LIGHT_THEME;
+  const selectedArboriumDarkTheme =
+    arboriumDarkThemes.find((theme) => theme.id === arboriumDarkTheme)?.id ??
+    DEFAULT_ARBORIUM_DARK_THEME;
+
+  return (
+    
+      
+         ({
+                value: theme.id,
+                label: getArboriumThemeLabel(theme.id),
+              }))}
+              onSelect={setArboriumLightTheme}
+            />
+          }
+        />
+         ({
+                value: theme.id,
+                label: getArboriumThemeLabel(theme.id),
+              }))}
+              onSelect={setArboriumDarkTheme}
+            />
+          }
+        />
+      
+    
+  );
+}
+
 function ThemeSettings() {
   const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
   const [saturation, setSaturation] = useSetting(settingsAtom, 'saturationLevel');
@@ -277,6 +228,8 @@ function ThemeSettings() {
         />
       
 
+      
+
       
         
   );
 }
+
 export function Appearance() {
   const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
   const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs');
diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx
index 5ee60a178..c66ddea30 100644
--- a/src/app/features/settings/notifications/AllMessages.tsx
+++ b/src/app/features/settings/notifications/AllMessages.tsx
@@ -11,16 +11,19 @@ import { useAccountData } from '$hooks/useAccountData';
 import { AccountDataEvent } from '$types/matrix/accountData';
 import { SequenceCard } from '$components/sequence-card';
 import { SettingTile } from '$components/setting-tile';
+import { SettingMenuSelector } from '$components/setting-menu-selector';
 import { PushRuleData, usePushRule } from '$hooks/usePushRule';
 import {
   getNotificationModeActions,
   NotificationMode,
+  useNotificationActionsMode,
   useNotificationModeActions,
 } from '$hooks/useNotificationMode';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
 import { useMatrixClient } from '$hooks/useMatrixClient';
 import { SequenceCardStyle } from '$features/settings/styles.css';
-import { NotificationModeSwitcher } from './NotificationModeSwitcher';
 import { NotificationLevelsHint } from './NotificationLevelsHint';
+import { notificationModeSelectorOptions } from './notificationModeOptions';
 
 const getAllMessageDefaultRule = (
   ruleId: RuleId,
@@ -67,16 +70,25 @@ function AllMessagesModeSwitcher({
   const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
   const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
   const getModeActions = useNotificationModeActions();
-
-  const handleChange = useCallback(
-    async (mode: NotificationMode) => {
-      const actions = getModeActions(mode);
-      await mx.setPushRuleActions('global', kind, ruleId, actions);
-    },
-    [mx, getModeActions, kind, ruleId]
+  const selectedMode = useNotificationActionsMode(pushRule.actions);
+  const [changeState, change] = useAsyncCallback(
+    useCallback(
+      async (mode: NotificationMode) => {
+        const actions = getModeActions(mode);
+        await mx.setPushRuleActions('global', kind, ruleId, actions);
+      },
+      [mx, getModeActions, kind, ruleId]
+    )
   );
 
-  return ;
+  return (
+    
+  );
 }
 
 export function AllMessagesNotifications() {
diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx
index ff235271f..4c7ab55f0 100644
--- a/src/app/features/settings/notifications/KeywordMessages.tsx
+++ b/src/app/features/settings/notifications/KeywordMessages.tsx
@@ -5,17 +5,19 @@ import { useAccountData } from '$hooks/useAccountData';
 import { AccountDataEvent } from '$types/matrix/accountData';
 import { SequenceCard } from '$components/sequence-card';
 import { SettingTile } from '$components/setting-tile';
+import { SettingMenuSelector } from '$components/setting-menu-selector';
 import { useMatrixClient } from '$hooks/useMatrixClient';
 import {
   getNotificationModeActions,
   NotificationMode,
   NotificationModeOptions,
+  useNotificationActionsMode,
   useNotificationModeActions,
 } from '$hooks/useNotificationMode';
 import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
 import { SequenceCardStyle } from '$features/settings/styles.css';
-import { NotificationModeSwitcher } from './NotificationModeSwitcher';
 import { NotificationLevelsHint } from './NotificationLevelsHint';
+import { notificationModeSelectorOptions } from './notificationModeOptions';
 
 const NOTIFY_MODE_OPS: NotificationModeOptions = {
   highlight: true,
@@ -129,21 +131,30 @@ function KeywordModeSwitcher({ pushRule }: PushRulesProps) {
   const mx = useMatrixClient();
 
   const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
-
-  const handleChange = useCallback(
-    async (mode: NotificationMode) => {
-      const actions = getModeActions(mode);
-      await mx.setPushRuleActions(
-        'global',
-        PushRuleKind.ContentSpecific,
-        pushRule.rule_id,
-        actions
-      );
-    },
-    [mx, getModeActions, pushRule]
+  const selectedMode = useNotificationActionsMode(pushRule.actions);
+  const [changeState, change] = useAsyncCallback(
+    useCallback(
+      async (mode: NotificationMode) => {
+        const actions = getModeActions(mode);
+        await mx.setPushRuleActions(
+          'global',
+          PushRuleKind.ContentSpecific,
+          pushRule.rule_id,
+          actions
+        );
+      },
+      [mx, getModeActions, pushRule]
+    )
   );
 
-  return ;
+  return (
+    
+  );
 }
 
 export function KeywordMessagesNotifications() {
diff --git a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx b/src/app/features/settings/notifications/NotificationModeSwitcher.tsx
deleted file mode 100644
index 608bd5078..000000000
--- a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import {
-  Box,
-  Button,
-  config,
-  Icon,
-  Icons,
-  Menu,
-  MenuItem,
-  PopOut,
-  RectCords,
-  Spinner,
-  Text,
-} from 'folds';
-import { IPushRule } from '$types/matrix-sdk';
-import { MouseEventHandler, useMemo, useState } from 'react';
-import FocusTrap from 'focus-trap-react';
-import { NotificationMode, useNotificationActionsMode } from '$hooks/useNotificationMode';
-import { stopPropagation } from '$utils/keyboard';
-import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
-
-export const useNotificationModes = (): NotificationMode[] =>
-  useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []);
-
-const useNotificationModeStr = (): Record =>
-  useMemo(
-    () => ({
-      [NotificationMode.OFF]: 'Disable',
-      [NotificationMode.Notify]: 'Notify Silent',
-      [NotificationMode.NotifyLoud]: 'Notify Loud',
-    }),
-    []
-  );
-
-type NotificationModeSwitcherProps = {
-  pushRule: IPushRule;
-  onChange: (mode: NotificationMode) => Promise;
-};
-export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) {
-  const modes = useNotificationModes();
-  const modeToStr = useNotificationModeStr();
-  const selectedMode = useNotificationActionsMode(pushRule.actions);
-  const [changeState, change] = useAsyncCallback(onChange);
-  const changing = changeState.status === AsyncStatus.Loading;
-
-  const [menuCords, setMenuCords] = useState();
-
-  const handleMenu: MouseEventHandler = (evt) => {
-    setMenuCords(evt.currentTarget.getBoundingClientRect());
-  };
-
-  const handleSelect = (mode: NotificationMode) => {
-    setMenuCords(undefined);
-    change(mode);
-  };
-
-  return (
-    <>
-      
-       setMenuCords(undefined),
-              clickOutsideDeactivates: true,
-              isKeyForward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
-              isKeyBackward: (evt: KeyboardEvent) =>
-                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
-              escapeDeactivates: stopPropagation,
-            }}
-          >
-            
-              
-                {modes.map((mode) => (
-                   handleSelect(mode)}
-                  >
-                    
-                      {modeToStr[mode]}
-                    
-                  
-                ))}
-              
-            
-          
-        }
-      />
-    
-  );
-}
diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx
index ac8d736f3..261ea97c2 100644
--- a/src/app/features/settings/notifications/SpecialMessages.tsx
+++ b/src/app/features/settings/notifications/SpecialMessages.tsx
@@ -5,6 +5,7 @@ import { useAccountData } from '$hooks/useAccountData';
 import { AccountDataEvent } from '$types/matrix/accountData';
 import { SequenceCard } from '$components/sequence-card';
 import { SettingTile } from '$components/setting-tile';
+import { SettingMenuSelector } from '$components/setting-menu-selector';
 import { useMatrixClient } from '$hooks/useMatrixClient';
 import { useUserProfile } from '$hooks/useUserProfile';
 import { getMxIdLocalPart } from '$utils/matrix';
@@ -13,11 +14,13 @@ import {
   getNotificationModeActions,
   NotificationMode,
   NotificationModeOptions,
+  useNotificationActionsMode,
   useNotificationModeActions,
 } from '$hooks/useNotificationMode';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
 import { SequenceCardStyle } from '$features/settings/styles.css';
-import { NotificationModeSwitcher } from './NotificationModeSwitcher';
 import { NotificationLevelsHint } from './NotificationLevelsHint';
+import { notificationModeSelectorOptions } from './notificationModeOptions';
 
 const NOTIFY_MODE_OPS: NotificationModeOptions = {
   highlight: true,
@@ -100,16 +103,25 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul
 
   const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
   const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
-
-  const handleChange = useCallback(
-    async (mode: NotificationMode) => {
-      const actions = getModeActions(mode);
-      await mx.setPushRuleActions('global', kind, ruleId, actions);
-    },
-    [mx, getModeActions, kind, ruleId]
+  const selectedMode = useNotificationActionsMode(pushRule.actions);
+  const [changeState, change] = useAsyncCallback(
+    useCallback(
+      async (mode: NotificationMode) => {
+        const actions = getModeActions(mode);
+        await mx.setPushRuleActions('global', kind, ruleId, actions);
+      },
+      [mx, getModeActions, kind, ruleId]
+    )
   );
 
-  return ;
+  return (
+    
+  );
 }
 
 export function SpecialMessagesNotifications() {
diff --git a/src/app/features/settings/notifications/notificationModeOptions.test.ts b/src/app/features/settings/notifications/notificationModeOptions.test.ts
new file mode 100644
index 000000000..b4e935d27
--- /dev/null
+++ b/src/app/features/settings/notifications/notificationModeOptions.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+
+import { NotificationMode } from '$hooks/useNotificationMode';
+
+import { notificationModeSelectorOptions } from './notificationModeOptions';
+
+describe('notificationModeSelectorOptions', () => {
+  it('returns the notification modes in display order with stable labels', () => {
+    expect(notificationModeSelectorOptions).toEqual([
+      { value: NotificationMode.NotifyLoud, label: 'Notify Loud' },
+      { value: NotificationMode.Notify, label: 'Notify Silent' },
+      { value: NotificationMode.OFF, label: 'Disable' },
+    ]);
+  });
+});
diff --git a/src/app/features/settings/notifications/notificationModeOptions.ts b/src/app/features/settings/notifications/notificationModeOptions.ts
new file mode 100644
index 000000000..b2fc74b62
--- /dev/null
+++ b/src/app/features/settings/notifications/notificationModeOptions.ts
@@ -0,0 +1,8 @@
+import { type SettingMenuOption } from '$components/setting-menu-selector';
+import { NotificationMode } from '$hooks/useNotificationMode';
+
+export const notificationModeSelectorOptions: SettingMenuOption[] = [
+  { value: NotificationMode.NotifyLoud, label: 'Notify Loud' },
+  { value: NotificationMode.Notify, label: 'Notify Silent' },
+  { value: NotificationMode.OFF, label: 'Disable' },
+];
diff --git a/src/app/pages/ThemeManager.test.tsx b/src/app/pages/ThemeManager.test.tsx
index d0afeb2d3..81cc4e991 100644
--- a/src/app/pages/ThemeManager.test.tsx
+++ b/src/app/pages/ThemeManager.test.tsx
@@ -78,16 +78,16 @@ afterEach(() => {
 });
 
 describe('ThemeManager', () => {
-  it('does not add Prism compatibility classes for unauthenticated routes', () => {
+  it('applies the system theme classes for unauthenticated routes', () => {
     systemThemeKind = ThemeKind.Dark;
 
     render();
 
-    expect(document.body).not.toHaveClass('prism-dark');
-    expect(document.body).not.toHaveClass('prism-light');
+    expect(document.body).toHaveClass('test-dark-theme');
+    expect(document.body).not.toHaveClass('test-light-theme');
   });
 
-  it('does not add Prism compatibility classes for authenticated routes', () => {
+  it('applies the active theme classes for authenticated routes', () => {
     activeTheme = {
       id: 'test-dark',
       kind: ThemeKind.Dark,
@@ -100,7 +100,7 @@ describe('ThemeManager', () => {
       
     );
 
-    expect(document.body).not.toHaveClass('prism-dark');
-    expect(document.body).not.toHaveClass('prism-light');
+    expect(document.body).toHaveClass('test-dark-theme');
+    expect(document.body).not.toHaveClass('test-light-theme');
   });
 });
diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx
index c2bf076f6..db6075040 100644
--- a/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx
+++ b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx
@@ -1,9 +1,17 @@
 import { afterEach, describe, expect, it } from 'vitest';
 import { act, render, screen } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+import { pluginVersion } from '@arborium/arborium';
 
 import { ThemeKind } from '$hooks/useTheme';
+import { getSettings, settingsAtom } from '$state/settings';
 
 import { ArboriumThemeBridge, useArboriumThemeStatus } from './ArboriumThemeBridge';
+import {
+  DEFAULT_ARBORIUM_DARK_THEME,
+  DEFAULT_ARBORIUM_LIGHT_THEME,
+  getArboriumThemeHref,
+} from './themes';
 
 function StatusProbe() {
   const { ready } = useArboriumThemeStatus();
@@ -11,10 +19,20 @@ function StatusProbe() {
   return 
{ready ? 'ready' : 'loading'}
; } -const pluginVersion = '2.16.0'; const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/base-rustdoc.css`; -const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/one-dark.css`; -const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/github-light.css`; + +function renderWithSettings(kind: ThemeKind, settings = getSettings()) { + const store = createStore(); + store.set(settingsAtom, settings); + + return render( + + + + + + ); +} afterEach(() => { document.getElementById('arborium-base')?.remove(); @@ -23,11 +41,7 @@ afterEach(() => { describe('ArboriumThemeBridge', () => { it('injects the base stylesheet once and swaps the theme stylesheet from dark to light', () => { - const { rerender } = render( - - - - ); + const { rerender } = renderWithSettings(ThemeKind.Dark); const baseLink = document.getElementById('arborium-base'); const themeLink = document.getElementById('arborium-theme'); @@ -35,7 +49,7 @@ describe('ArboriumThemeBridge', () => { expect(baseLink).toBeInstanceOf(HTMLLinkElement); expect(themeLink).toBeInstanceOf(HTMLLinkElement); expect(baseLink).toHaveAttribute('href', baseHref); - expect(themeLink).toHaveAttribute('href', darkHref); + expect(themeLink).toHaveAttribute('href', getArboriumThemeHref(DEFAULT_ARBORIUM_DARK_THEME)); expect(document.head.querySelectorAll('#arborium-base')).toHaveLength(1); expect(document.head.querySelectorAll('#arborium-theme')).toHaveLength(1); expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); @@ -48,9 +62,11 @@ describe('ArboriumThemeBridge', () => { expect(screen.getByTestId('arborium-status')).toHaveTextContent('ready'); rerender( - - - + + + + + ); const nextBaseLink = document.getElementById('arborium-base'); @@ -60,7 +76,37 @@ describe('ArboriumThemeBridge', () => { expect(nextThemeLink).toBe(themeLink); expect(document.head.querySelectorAll('#arborium-base')).toHaveLength(1); expect(nextBaseLink).toHaveAttribute('href', baseHref); - expect(nextThemeLink).toHaveAttribute('href', lightHref); + expect(nextThemeLink).toHaveAttribute( + 'href', + getArboriumThemeHref(DEFAULT_ARBORIUM_LIGHT_THEME) + ); + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); + }); + + it('uses the configured Arborium theme ids from settings', () => { + const settings = { + ...getSettings(), + arboriumLightTheme: 'nord', + arboriumDarkTheme: 'dracula', + }; + + renderWithSettings(ThemeKind.Dark, settings); + + const themeLink = document.getElementById('arborium-theme'); + expect(themeLink).toHaveAttribute('href', getArboriumThemeHref('dracula')); + }); + + it('keeps readiness false when the theme stylesheet errors', () => { + renderWithSettings(ThemeKind.Dark); + + const baseLink = document.getElementById('arborium-base'); + const themeLink = document.getElementById('arborium-theme'); + + act(() => { + baseLink?.dispatchEvent(new Event('load')); + themeLink?.dispatchEvent(new Event('error')); + }); + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); }); }); diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.tsx index 20e8978e1..372180134 100644 --- a/src/app/plugins/arborium/ArboriumThemeBridge.tsx +++ b/src/app/plugins/arborium/ArboriumThemeBridge.tsx @@ -1,7 +1,16 @@ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'; -import { pluginVersion } from '@arborium/arborium'; import { ThemeKind } from '$hooks/useTheme'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; + +import { + ARBORIUM_CDN_VERSION, + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, + isArboriumThemeId, +} from './themes'; type ArboriumThemeStatus = { ready: boolean; @@ -23,9 +32,7 @@ type ArboriumThemeBridgeProps = { children?: ReactNode; }; -const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/base-rustdoc.css`; -const darkHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/one-dark.css`; -const lightHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/github-light.css`; +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${ARBORIUM_CDN_VERSION}/dist/themes/base-rustdoc.css`; const baseLinkId = 'arborium-base'; const themeLinkId = 'arborium-theme'; @@ -59,8 +66,19 @@ const clearLinkLoaded = (link: HTMLLinkElement) => { }; export function ArboriumThemeBridge({ kind, children }: ArboriumThemeBridgeProps) { + const [arboriumLightTheme] = useSetting(settingsAtom, 'arboriumLightTheme'); + const [arboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); const [baseReady, setBaseReady] = useState(false); const [themeReady, setThemeReady] = useState(false); + const selectedThemeId = kind === ThemeKind.Dark ? arboriumDarkTheme : arboriumLightTheme; + let themeId = DEFAULT_ARBORIUM_LIGHT_THEME; + if (kind === ThemeKind.Dark) { + themeId = DEFAULT_ARBORIUM_DARK_THEME; + } + if (selectedThemeId && isArboriumThemeId(selectedThemeId)) { + themeId = selectedThemeId; + } + const themeHref = getArboriumThemeHref(themeId); useEffect(() => { const baseLink = getOrCreateLink(baseLinkId); @@ -87,9 +105,8 @@ export function ArboriumThemeBridge({ kind, children }: ArboriumThemeBridgeProps useEffect(() => { const themeLink = getOrCreateLink(themeLinkId); - const href = kind === ThemeKind.Dark ? darkHref : lightHref; - const hrefChanged = themeLink.getAttribute('href') !== href; - setLinkHref(themeLink, href); + const hrefChanged = themeLink.getAttribute('href') !== themeHref; + setLinkHref(themeLink, themeHref); if (hrefChanged) { clearLinkLoaded(themeLink); } @@ -111,7 +128,7 @@ export function ArboriumThemeBridge({ kind, children }: ArboriumThemeBridgeProps themeLink.removeEventListener('load', handleThemeLoad); themeLink.removeEventListener('error', handleThemeError); }; - }, [kind]); + }, [themeHref]); const status = useMemo(() => ({ ready: baseReady && themeReady }), [baseReady, themeReady]); diff --git a/src/app/plugins/arborium/index.ts b/src/app/plugins/arborium/index.ts index 8f95a191d..9a76a33c1 100644 --- a/src/app/plugins/arborium/index.ts +++ b/src/app/plugins/arborium/index.ts @@ -1,3 +1,10 @@ export type { HighlightCodeDeps, HighlightCodeInput, HighlightResult } from './runtime'; export { highlightCode } from './runtime'; export { ArboriumThemeBridge, useArboriumThemeStatus } from './ArboriumThemeBridge'; +export { + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, + getArboriumThemeLabel, + getArboriumThemeOptions, +} from './themes'; diff --git a/src/app/plugins/arborium/themes.test.ts b/src/app/plugins/arborium/themes.test.ts new file mode 100644 index 000000000..1d9599153 --- /dev/null +++ b/src/app/plugins/arborium/themes.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; + +import { pluginVersion } from '@arborium/arborium'; + +import { + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, + getArboriumThemeLabel, + getArboriumThemeOptions, +} from './themes'; + +const arboriumThemesDir = dirname( + createRequire(import.meta.url).resolve('@arborium/arborium/themes/base.css') +); + +describe('Arborium theme registry', () => { + it('exposes the default light and dark themes', () => { + expect(DEFAULT_ARBORIUM_LIGHT_THEME).toBe('github-light'); + expect(DEFAULT_ARBORIUM_DARK_THEME).toBe('one-dark'); + }); + + it('groups themes by kind and keeps the defaults available', () => { + const lightThemes = getArboriumThemeOptions('light'); + const darkThemes = getArboriumThemeOptions('dark'); + + expect(lightThemes.map((theme) => theme.id)).toContain(DEFAULT_ARBORIUM_LIGHT_THEME); + expect(darkThemes.map((theme) => theme.id)).toContain(DEFAULT_ARBORIUM_DARK_THEME); + }); + + it('builds the CDN href for a theme id', () => { + expect(getArboriumThemeHref('github-light')).toBe( + `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/github-light.css` + ); + }); + + it('returns a readable label for a supported theme', () => { + expect(getArboriumThemeLabel('github-light')).toBe('GitHub Light'); + }); + + it('matches the installed Arborium theme css files', async () => { + const installedThemes = (await readdir(arboriumThemesDir)) + .filter((fileName) => fileName.endsWith('.css')) + .map((fileName) => fileName.replace(/\.css$/, '')) + .filter((themeId) => themeId !== 'base' && themeId !== 'base-rustdoc') + .sort(); + + const exportedThemeIds = [ + ...getArboriumThemeOptions('light').map((theme) => theme.id), + ...getArboriumThemeOptions('dark').map((theme) => theme.id), + ].sort(); + + expect(exportedThemeIds).toEqual(installedThemes); + }); +}); diff --git a/src/app/plugins/arborium/themes.ts b/src/app/plugins/arborium/themes.ts new file mode 100644 index 000000000..d9edf51da --- /dev/null +++ b/src/app/plugins/arborium/themes.ts @@ -0,0 +1,67 @@ +export type ArboriumThemeKind = 'light' | 'dark'; + +export const ARBORIUM_CDN_VERSION = '2.16.0'; + +const ARBORIUM_THEME_DEFINITIONS = [ + { id: 'alabaster', label: 'Alabaster', kind: 'light' }, + { id: 'ayu-light', label: 'Ayu Light', kind: 'light' }, + { id: 'catppuccin-latte', label: 'Catppuccin Latte', kind: 'light' }, + { id: 'dayfox', label: 'Dayfox', kind: 'light' }, + { id: 'desert256', label: 'Desert 256', kind: 'light' }, + { id: 'github-light', label: 'GitHub Light', kind: 'light' }, + { id: 'gruvbox-light', label: 'Gruvbox Light', kind: 'light' }, + { id: 'light-owl', label: 'Light Owl', kind: 'light' }, + { id: 'lucius-light', label: 'Lucius Light', kind: 'light' }, + { id: 'melange-light', label: 'Melange Light', kind: 'light' }, + { id: 'solarized-light', label: 'Solarized Light', kind: 'light' }, + { id: 'rustdoc-light', label: 'Rustdoc Light', kind: 'light' }, + { id: 'github-dark', label: 'GitHub Dark', kind: 'dark' }, + { id: 'one-dark', label: 'One Dark', kind: 'dark' }, + { id: 'nord', label: 'Nord', kind: 'dark' }, + { id: 'dracula', label: 'Dracula', kind: 'dark' }, + { id: 'tokyo-night', label: 'Tokyo Night', kind: 'dark' }, + { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', kind: 'dark' }, + { id: 'catppuccin-macchiato', label: 'Catppuccin Macchiato', kind: 'dark' }, + { id: 'catppuccin-frappe', label: 'Catppuccin Frappe', kind: 'dark' }, + { id: 'rose-pine-moon', label: 'Rose Pine Moon', kind: 'dark' }, + { id: 'gruvbox-dark', label: 'Gruvbox Dark', kind: 'dark' }, + { id: 'ayu-dark', label: 'Ayu Dark', kind: 'dark' }, + { id: 'kanagawa-dragon', label: 'Kanagawa Dragon', kind: 'dark' }, + { id: 'solarized-dark', label: 'Solarized Dark', kind: 'dark' }, + { id: 'melange-dark', label: 'Melange Dark', kind: 'dark' }, + { id: 'monokai', label: 'Monokai', kind: 'dark' }, + { id: 'zenburn', label: 'Zenburn', kind: 'dark' }, + { id: 'cobalt2', label: 'Cobalt2', kind: 'dark' }, + { id: 'ef-melissa-dark', label: 'Ef Melissa Dark', kind: 'dark' }, + { id: 'rustdoc-dark', label: 'Rustdoc Dark', kind: 'dark' }, + { id: 'rustdoc-ayu', label: 'Rustdoc Ayu', kind: 'dark' }, +] as const; + +type ArboriumThemeDefinition = (typeof ARBORIUM_THEME_DEFINITIONS)[number]; + +export type ArboriumThemeId = ArboriumThemeDefinition['id']; + +export type ArboriumTheme = { + id: ArboriumThemeId; + label: string; + kind: ArboriumThemeKind; +}; + +export const DEFAULT_ARBORIUM_LIGHT_THEME: ArboriumThemeId = 'github-light'; +export const DEFAULT_ARBORIUM_DARK_THEME: ArboriumThemeId = 'one-dark'; + +const ARBORIUM_THEMES: ArboriumTheme[] = [...ARBORIUM_THEME_DEFINITIONS]; + +const ARBORIUM_THEME_IDS = new Set(ARBORIUM_THEMES.map((theme) => theme.id)); + +export const getArboriumThemeOptions = (kind: ArboriumThemeKind): ArboriumTheme[] => + ARBORIUM_THEMES.filter((theme) => theme.kind === kind); + +export const isArboriumThemeId = (themeId: string): themeId is ArboriumThemeId => + ARBORIUM_THEME_IDS.has(themeId as ArboriumThemeId); + +export const getArboriumThemeLabel = (themeId: ArboriumThemeId): string => + ARBORIUM_THEMES.find((theme) => theme.id === themeId)?.label ?? themeId; + +export const getArboriumThemeHref = (themeId: ArboriumThemeId): string => + `https://cdn.jsdelivr.net/npm/@arborium/arborium@${ARBORIUM_CDN_VERSION}/dist/themes/${themeId}.css`; diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 53e73aab4..80892dc83 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import parse from 'html-react-parser'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as css from '$styles/CustomHtml.css'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from './react-custom-html-parser'; @@ -63,6 +64,7 @@ describe('getReactCustomHtmlParser code blocks', () => { expect(arboriumCode).toHaveTextContent('fn main()'); expect(arboriumCode).toHaveAttribute('data-language', 'rust'); expect(arboriumCode).toHaveAttribute('data-allow-detect', 'false'); + expect(container.querySelector('#code-block-content')).toHaveClass(css.CodeBlockInternal); expect(CodeHighlightRenderer).toHaveBeenCalledWith( expect.objectContaining({ code: expect.stringContaining('let fifteenth = 15;'), diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index cfa533866..89e39371f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -28,6 +28,8 @@ export interface Settings { useSystemTheme: boolean; lightThemeId?: string; darkThemeId?: string; + arboriumLightTheme?: string; + arboriumDarkTheme?: string; saturationLevel?: number; uniformIcons: boolean; isMarkdown: boolean; @@ -118,6 +120,8 @@ const defaultSettings: Settings = { useSystemTheme: true, lightThemeId: undefined, darkThemeId: undefined, + arboriumLightTheme: 'github-light', + arboriumDarkTheme: 'one-dark', saturationLevel: 100, uniformIcons: false, isMarkdown: true, diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index 6ff53d9d5..8669b7fa5 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -106,6 +106,9 @@ export const CodeBlockInternal = style([ { padding: `${config.space.S200} ${config.space.S200} 0`, minWidth: toRem(200), + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + wordBreak: 'break-word', }, ]); diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 66c4f5274..da0ad3189 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -40,7 +40,14 @@ describe('NON_SYNCABLE_KEYS', () => { }); it('does not include ordinary syncable keys', () => { - const syncable = ['isMarkdown', 'twitterEmoji', 'messageLayout', 'urlPreview'] as const; + const syncable = [ + 'isMarkdown', + 'twitterEmoji', + 'messageLayout', + 'urlPreview', + 'arboriumLightTheme', + 'arboriumDarkTheme', + ] as const; syncable.forEach((key) => { expect(NON_SYNCABLE_KEYS.has(key)).toBe(false); }); From 2d26424cdf5a2bef83b487e42593397ffe2db6c2 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 28 Mar 2026 20:17:38 +0100 Subject: [PATCH 19/23] chore: add Arborium migration changeset --- .changeset/change-code-block-highlighting-to-arborium.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/change-code-block-highlighting-to-arborium.md diff --git a/.changeset/change-code-block-highlighting-to-arborium.md b/.changeset/change-code-block-highlighting-to-arborium.md new file mode 100644 index 000000000..acf2af689 --- /dev/null +++ b/.changeset/change-code-block-highlighting-to-arborium.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Change code block highlighting to Arborium and add separate light and dark code block theme options. From a458be5dafd46f3d778ba94f925f495a8fdae0aa Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 28 Mar 2026 20:21:46 +0100 Subject: [PATCH 20/23] chore: clarify Arborium changeset summary --- .changeset/change-code-block-highlighting-to-arborium.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/change-code-block-highlighting-to-arborium.md b/.changeset/change-code-block-highlighting-to-arborium.md index acf2af689..1071ef3dc 100644 --- a/.changeset/change-code-block-highlighting-to-arborium.md +++ b/.changeset/change-code-block-highlighting-to-arborium.md @@ -2,4 +2,4 @@ default: minor --- -Change code block highlighting to Arborium and add separate light and dark code block theme options. +Improve code blocks with faster, more accurate syntax highlighting, broader language support, and separate light and dark theme options. From 13bf74da874e25bc0a6630c22995d742d43b0ae1 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 28 Mar 2026 20:45:50 +0100 Subject: [PATCH 21/23] feat: add system and manual code block theme modes --- .../settings/cosmetics/Themes.test.tsx | 52 +++++- .../features/settings/cosmetics/Themes.tsx | 155 +++++++++++++----- .../arborium/ArboriumThemeBridge.test.tsx | 58 ++++++- .../plugins/arborium/ArboriumThemeBridge.tsx | 16 +- src/app/state/settings.ts | 4 + src/app/utils/settingsSync.test.ts | 2 + 6 files changed, 232 insertions(+), 55 deletions(-) diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx index 1aee22d02..c54ed6cdd 100644 --- a/src/app/features/settings/cosmetics/Themes.test.tsx +++ b/src/app/features/settings/cosmetics/Themes.test.tsx @@ -8,6 +8,8 @@ type SettingsShape = { useSystemTheme: boolean; lightThemeId?: string; darkThemeId?: string; + useSystemArboriumTheme: boolean; + arboriumThemeId?: string; arboriumLightTheme?: string; arboriumDarkTheme?: string; saturationLevel: number; @@ -48,11 +50,21 @@ vi.mock('$hooks/useTheme', async () => { beforeEach(() => { setters.clear(); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); currentSettings = { themeId: 'silver-theme', useSystemTheme: true, lightThemeId: 'cinny-light-theme', darkThemeId: 'black-theme', + useSystemArboriumTheme: true, + arboriumThemeId: 'dracula', arboriumLightTheme: 'github-light', arboriumDarkTheme: 'one-dark', saturationLevel: 100, @@ -70,6 +82,7 @@ beforeEach(() => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); const clickLatestButton = (name: string) => { @@ -78,7 +91,7 @@ const clickLatestButton = (name: string) => { }; describe('Appearance settings', () => { - it('renders shared selector-backed theme controls and Arborium code block selectors', () => { + it('renders shared selector-backed theme controls and code block system/manual selectors', () => { render(); expect(screen.getByRole('button', { name: 'Silver' })).toBeInTheDocument(); @@ -86,12 +99,14 @@ describe('Appearance settings', () => { expect(screen.getByRole('button', { name: 'Black' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'One Dark' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dracula' })).toBeDisabled(); }); - it('updates the manual and Arborium theme settings when selections change', () => { + it('updates the manual app and code block theme settings when system theme is disabled', () => { currentSettings = { ...currentSettings, useSystemTheme: false, + useSystemArboriumTheme: false, }; render(); @@ -99,15 +114,11 @@ describe('Appearance settings', () => { fireEvent.click(screen.getByRole('button', { name: 'Silver' })); clickLatestButton('Dark'); - fireEvent.click(screen.getByRole('button', { name: 'GitHub Light' })); + fireEvent.click(screen.getByRole('button', { name: 'Dracula' })); clickLatestButton('Ayu Light'); - fireEvent.click(screen.getByRole('button', { name: 'One Dark' })); - clickLatestButton('Dracula'); - expect(getSetter('themeId')).toHaveBeenCalledWith('dark-theme'); - expect(getSetter('arboriumLightTheme')).toHaveBeenCalledWith('ayu-light'); - expect(getSetter('arboriumDarkTheme')).toHaveBeenCalledWith('dracula'); + expect(getSetter('arboriumThemeId')).toHaveBeenCalledWith('ayu-light'); }); it('updates the system theme settings when the chip selectors change', () => { @@ -123,6 +134,19 @@ describe('Appearance settings', () => { expect(getSetter('darkThemeId')).toHaveBeenCalledWith('dark-theme'); }); + it('updates the system code block theme settings when the chip selectors change', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'GitHub Light' })); + clickLatestButton('Ayu Light'); + + fireEvent.click(screen.getByRole('button', { name: 'One Dark' })); + clickLatestButton('Dracula'); + + expect(getSetter('arboriumLightTheme')).toHaveBeenCalledWith('ayu-light'); + expect(getSetter('arboriumDarkTheme')).toHaveBeenCalledWith('dracula'); + }); + it('falls back to light theme ids when the stored app theme ids are invalid', () => { currentSettings = { ...currentSettings, @@ -135,6 +159,18 @@ describe('Appearance settings', () => { expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument(); }); + it('falls back to the active code block system theme when the stored manual theme id is invalid', () => { + currentSettings = { + ...currentSettings, + useSystemArboriumTheme: false, + arboriumThemeId: 'not-a-theme', + }; + + render(); + + expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument(); + }); + it('falls back to the default light and dark theme ids for invalid system theme values', () => { currentSettings = { ...currentSettings, diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 8c3966c28..4e88648c9 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -16,6 +16,7 @@ import { LightTheme, Theme, ThemeKind, + useActiveTheme, useSystemThemeKind, useThemeNames, useThemes, @@ -31,6 +32,17 @@ function makeThemeOptions(themes: Theme[], themeNames: Record) { })); } +function makeArboriumThemeOptions(kind?: 'light' | 'dark') { + const themes = kind + ? getArboriumThemeOptions(kind) + : [...getArboriumThemeOptions('light'), ...getArboriumThemeOptions('dark')]; + + return themes.map((theme) => ({ + value: theme.id, + label: getArboriumThemeLabel(theme.id), + })); +} + function ThemeTrigger({ selectedLabel, onClick, @@ -135,61 +147,130 @@ function SystemThemePreferences() { ); } -function CodeBlockThemePreferences() { +function SelectCodeBlockTheme({ disabled }: Readonly<{ disabled?: boolean }>) { + const activeTheme = useActiveTheme(); + const [arboriumThemeId, setArboriumThemeId] = useSetting(settingsAtom, 'arboriumThemeId'); + const [arboriumLightTheme] = useSetting(settingsAtom, 'arboriumLightTheme'); + const [arboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); + + const arboriumThemeOptions = makeArboriumThemeOptions(); + const selectedSystemThemeId = + activeTheme.kind === ThemeKind.Dark + ? (makeArboriumThemeOptions('dark').find((theme) => theme.value === arboriumDarkTheme) + ?.value ?? DEFAULT_ARBORIUM_DARK_THEME) + : (makeArboriumThemeOptions('light').find((theme) => theme.value === arboriumLightTheme) + ?.value ?? DEFAULT_ARBORIUM_LIGHT_THEME); + const selectedArboriumThemeId = + arboriumThemeOptions.find((theme) => theme.value === arboriumThemeId)?.value ?? + selectedSystemThemeId; + + return ( + + ); +} + +function CodeBlockSystemThemePreferences() { + const activeTheme = useActiveTheme(); const [arboriumLightTheme, setArboriumLightTheme] = useSetting( settingsAtom, 'arboriumLightTheme' ); const [arboriumDarkTheme, setArboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); - const arboriumLightThemes = getArboriumThemeOptions('light'); - const arboriumDarkThemes = getArboriumThemeOptions('dark'); - + const arboriumLightThemeOptions = makeArboriumThemeOptions('light'); + const arboriumDarkThemeOptions = makeArboriumThemeOptions('dark'); const selectedArboriumLightTheme = - arboriumLightThemes.find((theme) => theme.id === arboriumLightTheme)?.id ?? + arboriumLightThemeOptions.find((theme) => theme.value === arboriumLightTheme)?.value ?? DEFAULT_ARBORIUM_LIGHT_THEME; const selectedArboriumDarkTheme = - arboriumDarkThemes.find((theme) => theme.id === arboriumDarkTheme)?.id ?? + arboriumDarkThemeOptions.find((theme) => theme.value === arboriumDarkTheme)?.value ?? DEFAULT_ARBORIUM_DARK_THEME; return ( - - + + ( + + )} + /> + } + /> + ( + + )} + /> + } + /> + + ); +} + +function CodeBlockThemeSettings() { + const [useSystemArboriumTheme, setUseSystemArboriumTheme] = useSetting( + settingsAtom, + 'useSystemArboriumTheme' + ); + + return ( + + Code Block Theme + + ({ - value: theme.id, - label: getArboriumThemeLabel(theme.id), - }))} - onSelect={setArboriumLightTheme} + } /> + {useSystemArboriumTheme && } + + + ({ - value: theme.id, - label: getArboriumThemeLabel(theme.id), - }))} - onSelect={setArboriumDarkTheme} - /> - } + title="Manual Theme" + description="Active when System Theme is disabled." + after={} /> - - +
+ ); } @@ -228,7 +309,7 @@ function ThemeSettings() { /> - + { }); describe('ArboriumThemeBridge', () => { - it('injects the base stylesheet once and swaps the theme stylesheet from dark to light', () => { - const { rerender } = renderWithSettings(ThemeKind.Dark); + it('injects the base stylesheet once and swaps the theme stylesheet from dark to light in system mode', () => { + const store = createStore(); + store.set(settingsAtom, getSettings()); + + const { rerender } = render( + + + + + + ); const baseLink = document.getElementById('arborium-base'); const themeLink = document.getElementById('arborium-theme'); @@ -62,7 +71,7 @@ describe('ArboriumThemeBridge', () => { expect(screen.getByTestId('arborium-status')).toHaveTextContent('ready'); rerender( - + @@ -83,10 +92,11 @@ describe('ArboriumThemeBridge', () => { expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); }); - it('uses the configured Arborium theme ids from settings', () => { + it('uses the configured Arborium theme ids from settings in system mode', () => { const settings = { ...getSettings(), - arboriumLightTheme: 'nord', + useSystemArboriumTheme: true, + arboriumLightTheme: 'ayu-light', arboriumDarkTheme: 'dracula', }; @@ -96,6 +106,44 @@ describe('ArboriumThemeBridge', () => { expect(themeLink).toHaveAttribute('href', getArboriumThemeHref('dracula')); }); + it('uses the configured manual Arborium theme id when system mode is disabled', () => { + const settings = { + ...getSettings(), + useSystemArboriumTheme: false, + arboriumThemeId: 'dracula', + arboriumLightTheme: 'ayu-light', + arboriumDarkTheme: 'one-dark', + }; + const store = createStore(); + store.set(settingsAtom, settings); + + const { rerender } = render( + + + + + + ); + + expect(document.getElementById('arborium-theme')).toHaveAttribute( + 'href', + getArboriumThemeHref('dracula') + ); + + rerender( + + + + + + ); + + expect(document.getElementById('arborium-theme')).toHaveAttribute( + 'href', + getArboriumThemeHref('dracula') + ); + }); + it('keeps readiness false when the theme stylesheet errors', () => { renderWithSettings(ThemeKind.Dark); diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.tsx index 372180134..5b193fab1 100644 --- a/src/app/plugins/arborium/ArboriumThemeBridge.tsx +++ b/src/app/plugins/arborium/ArboriumThemeBridge.tsx @@ -66,18 +66,24 @@ const clearLinkLoaded = (link: HTMLLinkElement) => { }; export function ArboriumThemeBridge({ kind, children }: ArboriumThemeBridgeProps) { + const [useSystemArboriumTheme] = useSetting(settingsAtom, 'useSystemArboriumTheme'); + const [arboriumThemeId] = useSetting(settingsAtom, 'arboriumThemeId'); const [arboriumLightTheme] = useSetting(settingsAtom, 'arboriumLightTheme'); const [arboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); const [baseReady, setBaseReady] = useState(false); const [themeReady, setThemeReady] = useState(false); - const selectedThemeId = kind === ThemeKind.Dark ? arboriumDarkTheme : arboriumLightTheme; - let themeId = DEFAULT_ARBORIUM_LIGHT_THEME; + const selectedSystemThemeId = kind === ThemeKind.Dark ? arboriumDarkTheme : arboriumLightTheme; + let resolvedSystemThemeId = DEFAULT_ARBORIUM_LIGHT_THEME; if (kind === ThemeKind.Dark) { - themeId = DEFAULT_ARBORIUM_DARK_THEME; + resolvedSystemThemeId = DEFAULT_ARBORIUM_DARK_THEME; } - if (selectedThemeId && isArboriumThemeId(selectedThemeId)) { - themeId = selectedThemeId; + if (selectedSystemThemeId && isArboriumThemeId(selectedSystemThemeId)) { + resolvedSystemThemeId = selectedSystemThemeId; } + const themeId = + !useSystemArboriumTheme && arboriumThemeId && isArboriumThemeId(arboriumThemeId) + ? arboriumThemeId + : resolvedSystemThemeId; const themeHref = getArboriumThemeHref(themeId); useEffect(() => { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 89e39371f..10c9435ee 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -28,6 +28,8 @@ export interface Settings { useSystemTheme: boolean; lightThemeId?: string; darkThemeId?: string; + useSystemArboriumTheme: boolean; + arboriumThemeId?: string; arboriumLightTheme?: string; arboriumDarkTheme?: string; saturationLevel?: number; @@ -120,6 +122,8 @@ const defaultSettings: Settings = { useSystemTheme: true, lightThemeId: undefined, darkThemeId: undefined, + useSystemArboriumTheme: true, + arboriumThemeId: 'one-dark', arboriumLightTheme: 'github-light', arboriumDarkTheme: 'one-dark', saturationLevel: 100, diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index da0ad3189..270d60a11 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -45,6 +45,8 @@ describe('NON_SYNCABLE_KEYS', () => { 'twitterEmoji', 'messageLayout', 'urlPreview', + 'useSystemArboriumTheme', + 'arboriumThemeId', 'arboriumLightTheme', 'arboriumDarkTheme', ] as const; From 0f7a28bf428057ba669b5052fd9a69e17c31f431 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 28 Mar 2026 22:36:32 +0100 Subject: [PATCH 22/23] refactor: split code block themes into their own section --- src/app/features/settings/cosmetics/Themes.test.tsx | 12 +++++++++++- src/app/features/settings/cosmetics/Themes.tsx | 3 +-- src/app/plugins/arborium/themes.ts | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx index c54ed6cdd..92ce5649c 100644 --- a/src/app/features/settings/cosmetics/Themes.test.tsx +++ b/src/app/features/settings/cosmetics/Themes.test.tsx @@ -91,9 +91,19 @@ const clickLatestButton = (name: string) => { }; describe('Appearance settings', () => { - it('renders shared selector-backed theme controls and code block system/manual selectors', () => { + it('renders Theme, Code Block Theme, and Visual Tweaks as separate sections', () => { render(); + const themeHeading = screen.getByText('Theme'); + const codeBlockThemeHeading = screen.getByText('Code Block Theme'); + const visualTweaksHeading = screen.getByText('Visual Tweaks'); + + expect(themeHeading.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(); diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 4e88648c9..707bab42c 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -309,8 +309,6 @@ function ThemeSettings() { /> - - + Visual Tweaks diff --git a/src/app/plugins/arborium/themes.ts b/src/app/plugins/arborium/themes.ts index d9edf51da..aace92928 100644 --- a/src/app/plugins/arborium/themes.ts +++ b/src/app/plugins/arborium/themes.ts @@ -48,7 +48,7 @@ export type ArboriumTheme = { }; export const DEFAULT_ARBORIUM_LIGHT_THEME: ArboriumThemeId = 'github-light'; -export const DEFAULT_ARBORIUM_DARK_THEME: ArboriumThemeId = 'one-dark'; +export const DEFAULT_ARBORIUM_DARK_THEME: ArboriumThemeId = 'github-dark'; const ARBORIUM_THEMES: ArboriumTheme[] = [...ARBORIUM_THEME_DEFINITIONS]; From 153a4903d31d249192cafaeceb6575b298829a28 Mon Sep 17 00:00:00 2001 From: hazre Date: Mon, 30 Mar 2026 00:03:57 +0200 Subject: [PATCH 23/23] fix: default dark code block theme to dracula --- .../features/settings/cosmetics/Themes.test.tsx | 15 +++++++++------ src/app/plugins/arborium/themes.test.ts | 2 +- src/app/plugins/arborium/themes.ts | 2 +- src/app/state/settings.ts | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx index 92ce5649c..ba621537d 100644 --- a/src/app/features/settings/cosmetics/Themes.test.tsx +++ b/src/app/features/settings/cosmetics/Themes.test.tsx @@ -66,7 +66,7 @@ beforeEach(() => { useSystemArboriumTheme: true, arboriumThemeId: 'dracula', arboriumLightTheme: 'github-light', - arboriumDarkTheme: 'one-dark', + arboriumDarkTheme: 'dracula', saturationLevel: 100, underlineLinks: false, reducedMotion: false, @@ -90,6 +90,9 @@ const clickLatestButton = (name: string) => { fireEvent.click(nodes.at(-1)!); }; +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', () => { render(); @@ -108,8 +111,8 @@ describe('Appearance settings', () => { expect(screen.getByRole('button', { name: 'Cinny Light' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Black' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'One Dark' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Dracula' })).toBeDisabled(); + expect(screen.getAllByRole('button', { name: 'Dracula' })).toHaveLength(2); + expect(screen.getAllByRole('button', { name: 'Dracula' }).at(-1)).toBeDisabled(); }); it('updates the manual app and code block theme settings when system theme is disabled', () => { @@ -150,11 +153,11 @@ describe('Appearance settings', () => { fireEvent.click(screen.getByRole('button', { name: 'GitHub Light' })); clickLatestButton('Ayu Light'); - fireEvent.click(screen.getByRole('button', { name: 'One Dark' })); - clickLatestButton('Dracula'); + fireEvent.click(getFirstEnabledButton('Dracula')!); + clickLatestButton('One Dark'); expect(getSetter('arboriumLightTheme')).toHaveBeenCalledWith('ayu-light'); - expect(getSetter('arboriumDarkTheme')).toHaveBeenCalledWith('dracula'); + expect(getSetter('arboriumDarkTheme')).toHaveBeenCalledWith('one-dark'); }); it('falls back to light theme ids when the stored app theme ids are invalid', () => { diff --git a/src/app/plugins/arborium/themes.test.ts b/src/app/plugins/arborium/themes.test.ts index 1d9599153..7f1ba1e0c 100644 --- a/src/app/plugins/arborium/themes.test.ts +++ b/src/app/plugins/arborium/themes.test.ts @@ -20,7 +20,7 @@ const arboriumThemesDir = dirname( describe('Arborium theme registry', () => { it('exposes the default light and dark themes', () => { expect(DEFAULT_ARBORIUM_LIGHT_THEME).toBe('github-light'); - expect(DEFAULT_ARBORIUM_DARK_THEME).toBe('one-dark'); + expect(DEFAULT_ARBORIUM_DARK_THEME).toBe('dracula'); }); it('groups themes by kind and keeps the defaults available', () => { diff --git a/src/app/plugins/arborium/themes.ts b/src/app/plugins/arborium/themes.ts index aace92928..ae250ff9b 100644 --- a/src/app/plugins/arborium/themes.ts +++ b/src/app/plugins/arborium/themes.ts @@ -48,7 +48,7 @@ export type ArboriumTheme = { }; export const DEFAULT_ARBORIUM_LIGHT_THEME: ArboriumThemeId = 'github-light'; -export const DEFAULT_ARBORIUM_DARK_THEME: ArboriumThemeId = 'github-dark'; +export const DEFAULT_ARBORIUM_DARK_THEME: ArboriumThemeId = 'dracula'; const ARBORIUM_THEMES: ArboriumTheme[] = [...ARBORIUM_THEME_DEFINITIONS]; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 10c9435ee..a081b0e48 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -123,9 +123,9 @@ const defaultSettings: Settings = { lightThemeId: undefined, darkThemeId: undefined, useSystemArboriumTheme: true, - arboriumThemeId: 'one-dark', + arboriumThemeId: 'dracula', arboriumLightTheme: 'github-light', - arboriumDarkTheme: 'one-dark', + arboriumDarkTheme: 'dracula', saturationLevel: 100, uniformIcons: false, isMarkdown: true,