feat(ui-next): Adds appearance dialog with theme presets and custom theme support (#6041)
This commit is contained in:
parent
0fcebe05c6
commit
0dfd321326
@ -43,6 +43,10 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }
|
||||
'ohif.aboutModal'
|
||||
) as Types.MenuComponentCustomization;
|
||||
|
||||
const AppearanceModal = customizationService.getCustomization(
|
||||
'ohif.appearanceModal'
|
||||
) as Types.MenuComponentCustomization;
|
||||
|
||||
const UserPreferencesModal = customizationService.getCustomization(
|
||||
'ohif.userPreferencesModal'
|
||||
) as Types.MenuComponentCustomization;
|
||||
@ -71,6 +75,19 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }
|
||||
},
|
||||
];
|
||||
|
||||
if (AppearanceModal) {
|
||||
menuOptions.splice(1, 0, {
|
||||
title: AppearanceModal.menuTitle ?? t('Header:Appearance'),
|
||||
icon: 'ColorChange',
|
||||
onClick: () =>
|
||||
show({
|
||||
content: AppearanceModal,
|
||||
title: AppearanceModal.title ?? t('AppearanceModal:Appearance'),
|
||||
containerClassName: AppearanceModal.containerClassName ?? 'max-w-md',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (appConfig.oidc) {
|
||||
menuOptions.push({
|
||||
title: t('Header:Logout'),
|
||||
|
||||
@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AppearanceModal,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Button,
|
||||
useActiveTheme,
|
||||
themePresets,
|
||||
} from '@ohif/ui-next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function AppearanceModalDefault() {
|
||||
const { activeTheme, setActiveTheme, customCss, applyCustomTheme, clearCustomTheme } =
|
||||
useActiveTheme();
|
||||
const { t } = useTranslation('AppearanceModal');
|
||||
|
||||
const [draftCss, setDraftCss] = React.useState(() => customCss);
|
||||
const [isCustomOpen, setIsCustomOpen] = React.useState(() => activeTheme === 'custom');
|
||||
const [parseFailed, setParseFailed] = React.useState(false);
|
||||
|
||||
const handleToggleCustom = () => {
|
||||
setIsCustomOpen(!isCustomOpen);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setParseFailed(!applyCustomTheme(draftCss));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearCustomTheme();
|
||||
setDraftCss('');
|
||||
setParseFailed(false);
|
||||
setIsCustomOpen(false);
|
||||
};
|
||||
|
||||
const handlePresetChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
// Selecting "Custom" restores the saved custom theme; the draft is only
|
||||
// committed through the explicit Apply button.
|
||||
setIsCustomOpen(true);
|
||||
if (customCss) {
|
||||
applyCustomTheme(customCss);
|
||||
}
|
||||
} else {
|
||||
setIsCustomOpen(false);
|
||||
setActiveTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDraftCss(e.target.value);
|
||||
setParseFailed(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppearanceModal>
|
||||
<AppearanceModal.Body>
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-6 gap-y-4">
|
||||
<AppearanceModal.SectionLabel>{t('Theme')}</AppearanceModal.SectionLabel>
|
||||
<div>
|
||||
<Select
|
||||
value={activeTheme}
|
||||
onValueChange={handlePresetChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[200px]"
|
||||
aria-label={t('Theme')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('Default Theme')}</SelectItem>
|
||||
{themePresets.map(preset => (
|
||||
<SelectItem
|
||||
key={preset.name}
|
||||
value={preset.name}
|
||||
>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(customCss || draftCss) && <SelectItem value="custom">{t('Custom')}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-start-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleCustom}
|
||||
>
|
||||
{isCustomOpen ? t('Hide') : t('Custom Theme')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCustomOpen && (
|
||||
<div className="mt-4 flex flex-col space-y-2">
|
||||
<textarea
|
||||
value={draftCss}
|
||||
onChange={handleTextChange}
|
||||
placeholder={t('Paste your custom theme color tokens here')}
|
||||
aria-label={t('Paste your custom theme color tokens here')}
|
||||
rows={8}
|
||||
className="bg-muted text-foreground border-input placeholder:text-muted-foreground focus:ring-ring rounded-md border px-3 py-2 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-inset"
|
||||
/>
|
||||
{parseFailed && (
|
||||
<span
|
||||
className="text-destructive text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{t('No valid theme tokens found')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppearanceModal.Body>
|
||||
</AppearanceModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
'ohif.appearanceModal': AppearanceModalDefault,
|
||||
};
|
||||
@ -18,6 +18,7 @@ import progressLoadingBarCustomization from './customizations/progressLoadingBar
|
||||
import labellingFlowCustomization from './customizations/labellingFlowCustomization';
|
||||
import viewportNotificationCustomization from './customizations/notificationCustomization';
|
||||
import aboutModalCustomization from './customizations/aboutModalCustomization';
|
||||
import appearanceModalCustomization from './customizations/appearanceModalCustomization';
|
||||
import userPreferencesCustomization from './customizations/userPreferencesCustomization';
|
||||
import reportDialogCustomization from './customizations/reportDialogCustomization';
|
||||
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
|
||||
@ -39,6 +40,10 @@ export default function getCustomizationModule({ servicesManager, extensionManag
|
||||
name: 'helloPage',
|
||||
value: helloPageCustomization,
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
value: appearanceModalCustomization,
|
||||
},
|
||||
{
|
||||
name: 'datasources',
|
||||
value: datasourcesCustomization,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DicomMetadataStore, classes } from '@ohif/core';
|
||||
import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv';
|
||||
import { ActiveThemeProvider } from '@ohif/ui-next';
|
||||
|
||||
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
|
||||
import { registerHangingProtocolAttributes } from './hangingprotocols';
|
||||
@ -16,7 +17,18 @@ export default function init({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager,
|
||||
serviceProvidersManager,
|
||||
appConfig,
|
||||
}: withAppTypes): void {
|
||||
const hasThemeModule =
|
||||
Array.isArray(appConfig.customizationService) &&
|
||||
appConfig.customizationService.some(
|
||||
ref => typeof ref === 'string' && ref.includes('customizationModule.theme')
|
||||
);
|
||||
|
||||
if (hasThemeModule) {
|
||||
serviceProvidersManager.registerProvider('activeTheme', ActiveThemeProvider);
|
||||
}
|
||||
const { toolbarService, cineService, viewportGridService } = servicesManager.services;
|
||||
|
||||
toolbarService.registerEventForToolbarUpdate(cineService, [
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
window.config = {
|
||||
routerBasename: null,
|
||||
customizationService: [
|
||||
'@ohif/extension-default.customizationModule.theme',
|
||||
],
|
||||
extensions: [],
|
||||
modes: [],
|
||||
showStudyList: true,
|
||||
|
||||
@ -30,7 +30,7 @@ export function defaultSettingsMenuItems({
|
||||
const items: SettingsMenuItem[] = [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About OHIF Viewer',
|
||||
label: t('Header:About'),
|
||||
onClick: () => {
|
||||
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
|
||||
show({
|
||||
@ -42,7 +42,7 @@ export function defaultSettingsMenuItems({
|
||||
},
|
||||
{
|
||||
id: 'userPreferences',
|
||||
label: 'User Preferences',
|
||||
label: t('Header:Preferences'),
|
||||
onClick: () => {
|
||||
const UserPreferencesModal = customizationService.getCustomization(
|
||||
'ohif.userPreferencesModal'
|
||||
@ -57,6 +57,21 @@ export function defaultSettingsMenuItems({
|
||||
},
|
||||
];
|
||||
|
||||
const AppearanceModal = customizationService.getCustomization('ohif.appearanceModal');
|
||||
if (AppearanceModal) {
|
||||
items.splice(1, 0, {
|
||||
id: 'appearance',
|
||||
label: AppearanceModal.menuTitle ?? t('Header:Appearance'),
|
||||
onClick: () => {
|
||||
show({
|
||||
content: AppearanceModal,
|
||||
title: AppearanceModal.title ?? t('AppearanceModal:Appearance'),
|
||||
containerClassName: AppearanceModal.containerClassName ?? 'max-w-md',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (appConfig.oidc) {
|
||||
items.push({
|
||||
id: 'logout',
|
||||
|
||||
12
platform/i18n/src/locales/en-US/AppearanceModal.json
Normal file
12
platform/i18n/src/locales/en-US/AppearanceModal.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"Appearance": "Appearance",
|
||||
"Theme": "Theme",
|
||||
"Default Theme": "Tonal: OHIF Blue",
|
||||
"Custom": "Custom",
|
||||
"Custom Theme": "Custom Theme",
|
||||
"Hide": "Hide",
|
||||
"Apply": "Apply",
|
||||
"Clear": "Clear",
|
||||
"No valid theme tokens found": "No valid theme tokens found. Expected lines like --primary: 220 90% 60%",
|
||||
"Paste your custom theme color tokens here": "Paste your custom theme color tokens here"
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"About": "About",
|
||||
"Appearance": "Appearance",
|
||||
"Back to Viewer": "Back to Viewer",
|
||||
"INVESTIGATIONAL USE ONLY": "INVESTIGATIONAL USE ONLY",
|
||||
"Options": "Options",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import AboutModal from './AboutModal.json';
|
||||
import AppearanceModal from './AppearanceModal.json';
|
||||
import Buttons from './Buttons.json';
|
||||
import CineDialog from './CineDialog.json';
|
||||
import Common from './Common.json';
|
||||
@ -36,6 +37,7 @@ import USAnnotationPanel from './USAnnotationPanel.json';
|
||||
export default {
|
||||
'en-US': {
|
||||
AboutModal,
|
||||
AppearanceModal,
|
||||
Buttons,
|
||||
CineDialog,
|
||||
Common,
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface AppearanceModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AppearanceModal({ children, className }: AppearanceModalProps) {
|
||||
return (
|
||||
<div className={cn('flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function Body({ children, className }: BodyProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-y-auto', className)}>
|
||||
<div className="mt-1 mb-4 flex flex-col space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionLabelProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function SectionLabel({ children, className }: SectionLabelProps) {
|
||||
return <span className={cn('text-muted-foreground text-lg', className)}>{children}</span>;
|
||||
}
|
||||
|
||||
AppearanceModal.Body = Body;
|
||||
AppearanceModal.SectionLabel = SectionLabel;
|
||||
@ -1,3 +1,4 @@
|
||||
export { UserPreferencesModal } from './UserPreferencesModal';
|
||||
export { ImageModal } from './ImageModal';
|
||||
export { AboutModal } from './AboutModal';
|
||||
export { AppearanceModal } from './AppearanceModal';
|
||||
|
||||
@ -69,7 +69,7 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tool
|
||||
import { ToolboxUI } from './OHIFToolbox';
|
||||
import Numeric from './Numeric';
|
||||
import { InputDialog, PresetDialog } from './OHIFDialogs';
|
||||
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
|
||||
import { AboutModal, ImageModal, UserPreferencesModal, AppearanceModal } from './OHIFModals';
|
||||
import Modal from './Modal/Modal';
|
||||
import { FooterAction } from './FooterAction';
|
||||
import { InputFilter } from './InputFilter';
|
||||
@ -296,6 +296,7 @@ export {
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
AppearanceModal,
|
||||
FooterAction,
|
||||
ToolSettings,
|
||||
InputFilter,
|
||||
|
||||
204
platform/ui-next/src/contextProviders/ActiveThemeProvider.tsx
Normal file
204
platform/ui-next/src/contextProviders/ActiveThemeProvider.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import * as React from 'react';
|
||||
import '../themes/themes.css';
|
||||
import { themePresets } from '../themes';
|
||||
|
||||
const STORAGE_KEY_THEME = 'ohif:theme';
|
||||
const STORAGE_KEY_CUSTOM_CSS = 'ohif:custom-theme-css';
|
||||
const CUSTOM_STYLE_ID = 'ohif-custom-theme';
|
||||
const VALID_THEMES = new Set(['default', 'custom', ...themePresets.map(p => p.name)]);
|
||||
|
||||
type ActiveThemeContextType = {
|
||||
activeTheme: string;
|
||||
setActiveTheme: (theme: string) => void;
|
||||
customCss: string;
|
||||
applyCustomTheme: (cssText: string) => boolean;
|
||||
clearCustomTheme: () => void;
|
||||
};
|
||||
|
||||
// 'custom' is deliberately not URL-addressable: the CSS behind it lives only in
|
||||
// the visitor's own localStorage, so a ?theme=custom link cannot reproduce a look
|
||||
// and would only strand the app in a custom state with no CSS behind it.
|
||||
function isValidUrlTheme(theme: string | null): theme is string {
|
||||
return !!theme && theme !== 'custom' && VALID_THEMES.has(theme);
|
||||
}
|
||||
|
||||
const ActiveThemeContext = React.createContext<ActiveThemeContextType | undefined>(undefined);
|
||||
|
||||
function removeThemeClasses() {
|
||||
const classList = document.body.classList;
|
||||
const toRemove: string[] = [];
|
||||
classList.forEach(cls => {
|
||||
if (cls.startsWith('theme-')) {
|
||||
toRemove.push(cls);
|
||||
}
|
||||
});
|
||||
toRemove.forEach(cls => classList.remove(cls));
|
||||
}
|
||||
|
||||
function removeCustomStyleElement() {
|
||||
const style = document.getElementById(CUSTOM_STYLE_ID);
|
||||
if (style) {
|
||||
style.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function parseCssVars(cssText: string): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
// Remove comment spans entirely (text included), then any unterminated tail,
|
||||
// so comment contents never bleed into a variable value.
|
||||
const stripped = cssText.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\*[\s\S]*$/, '');
|
||||
|
||||
// Split on newlines AND semicolons so single-line/minified CSS parses too.
|
||||
for (const raw of stripped.split(/[\n;]/)) {
|
||||
let line = raw.trim();
|
||||
|
||||
// Tolerate a selector prefix on the same segment, e.g. ":root {" or ".dark{".
|
||||
const braceIdx = line.lastIndexOf('{');
|
||||
if (braceIdx !== -1) {
|
||||
line = line.slice(braceIdx + 1).trim();
|
||||
}
|
||||
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const name = line.slice(0, colonIdx).trim();
|
||||
if (!/^--[a-zA-Z0-9-]+$/.test(name)) continue;
|
||||
|
||||
const value = line
|
||||
.slice(colonIdx + 1)
|
||||
.replace(/[{}]/g, '')
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
|
||||
// Token values are plain HSL triplets — parentheses are never legitimate,
|
||||
// and rejecting them keeps url(...) out of the injected stylesheet even if
|
||||
// a future rule ever consumes a token outside hsl().
|
||||
if (value.includes('(') || value.includes(')')) continue;
|
||||
|
||||
vars.push(` ${name}: ${value};`);
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
function injectCustomStyles(vars: string[]) {
|
||||
const block = vars.join('\n');
|
||||
const css = `:root {\n${block}\n}\n.dark {\n${block}\n}`;
|
||||
|
||||
let style = document.getElementById(CUSTOM_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = CUSTOM_STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
}
|
||||
|
||||
export function ActiveThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeTheme, setActiveThemeState] = React.useState<string>(() => {
|
||||
if (typeof window === 'undefined') return 'default';
|
||||
const urlTheme = new URLSearchParams(window.location.search).get('theme');
|
||||
if (isValidUrlTheme(urlTheme)) return urlTheme;
|
||||
|
||||
// Validate the stored value the same way the URL param is validated — a stale
|
||||
// key (renamed preset, older build) must not become a theme-* body class.
|
||||
const stored = localStorage.getItem(STORAGE_KEY_THEME);
|
||||
if (!stored || !VALID_THEMES.has(stored)) return 'default';
|
||||
if (stored === 'custom' && !localStorage.getItem(STORAGE_KEY_CUSTOM_CSS)) return 'default';
|
||||
return stored;
|
||||
});
|
||||
|
||||
const [customCss, setCustomCssState] = React.useState<string>(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return localStorage.getItem(STORAGE_KEY_CUSTOM_CSS) || '';
|
||||
});
|
||||
|
||||
const setActiveTheme = React.useCallback((theme: string) => {
|
||||
if (!VALID_THEMES.has(theme)) return;
|
||||
|
||||
setActiveThemeState(theme);
|
||||
removeThemeClasses();
|
||||
|
||||
if (theme !== 'default' && theme !== 'custom') {
|
||||
document.body.classList.add(`theme-${theme}`);
|
||||
}
|
||||
|
||||
if (theme !== 'custom') {
|
||||
removeCustomStyleElement();
|
||||
}
|
||||
|
||||
if (theme === 'default') {
|
||||
localStorage.removeItem(STORAGE_KEY_THEME);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY_THEME, theme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyCustomTheme = React.useCallback((cssText: string): boolean => {
|
||||
const vars = parseCssVars(cssText);
|
||||
if (vars.length === 0) return false;
|
||||
|
||||
injectCustomStyles(vars);
|
||||
|
||||
setCustomCssState(cssText);
|
||||
localStorage.setItem(STORAGE_KEY_CUSTOM_CSS, cssText);
|
||||
|
||||
setActiveThemeState('custom');
|
||||
removeThemeClasses();
|
||||
localStorage.setItem(STORAGE_KEY_THEME, 'custom');
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const clearCustomTheme = React.useCallback(() => {
|
||||
removeCustomStyleElement();
|
||||
|
||||
setCustomCssState('');
|
||||
localStorage.removeItem(STORAGE_KEY_CUSTOM_CSS);
|
||||
|
||||
setActiveThemeState('default');
|
||||
removeThemeClasses();
|
||||
localStorage.removeItem(STORAGE_KEY_THEME);
|
||||
}, []);
|
||||
|
||||
// Persist URL param override to localStorage
|
||||
React.useEffect(() => {
|
||||
const urlTheme = new URLSearchParams(window.location.search).get('theme');
|
||||
if (isValidUrlTheme(urlTheme)) {
|
||||
if (urlTheme === 'default') {
|
||||
localStorage.removeItem(STORAGE_KEY_THEME);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY_THEME, urlTheme);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Apply initial theme on mount (including custom theme re-injection on reload)
|
||||
React.useEffect(() => {
|
||||
if (activeTheme === 'custom' && customCss) {
|
||||
applyCustomTheme(customCss);
|
||||
} else if (activeTheme !== 'default') {
|
||||
document.body.classList.add(`theme-${activeTheme}`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeThemeClasses();
|
||||
removeCustomStyleElement();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ activeTheme, setActiveTheme, customCss, applyCustomTheme, clearCustomTheme }),
|
||||
[activeTheme, setActiveTheme, customCss, applyCustomTheme, clearCustomTheme]
|
||||
);
|
||||
|
||||
return <ActiveThemeContext.Provider value={value}>{children}</ActiveThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useActiveTheme() {
|
||||
const context = React.useContext(ActiveThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useActiveTheme must be used within an ActiveThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -21,3 +21,6 @@ export { ImageViewerContext, ImageViewerProvider, useImageViewer };
|
||||
export { DragAndDropProvider };
|
||||
export { CineProvider, useCine };
|
||||
export { IconPresentationProvider, useIconPresentation } from './IconPresentationProvider';
|
||||
export { ActiveThemeProvider, useActiveTheme } from './ActiveThemeProvider';
|
||||
export { themePresets } from '../themes';
|
||||
export type { ThemePreset } from '../themes';
|
||||
|
||||
31
platform/ui-next/src/themes/arctic.json
Normal file
31
platform/ui-next/src/themes/arctic.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "arctic",
|
||||
"label": "Tonal: Arctic",
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"highlight": "173 81% 52%",
|
||||
"neutral": "185 20% 55%",
|
||||
"neutral-light": "180 40% 78%",
|
||||
"neutral-dark": "190 18% 22%",
|
||||
"background": "0 0% 0%",
|
||||
"foreground": "180 10% 97%",
|
||||
"card": "203 39% 9%",
|
||||
"card-foreground": "180 10% 97%",
|
||||
"popover": "202 54% 11%",
|
||||
"popover-foreground": "180 10% 97%",
|
||||
"primary": "175 85% 42%",
|
||||
"primary-foreground": "185 50% 8%",
|
||||
"secondary": "180 45% 28%",
|
||||
"secondary-foreground": "175 50% 88%",
|
||||
"muted": "203 39% 9%",
|
||||
"muted-foreground": "185 30% 60%",
|
||||
"accent": "185 55% 18%",
|
||||
"accent-foreground": "180 10% 97%",
|
||||
"destructive": "0 65% 35%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "190 25% 17%",
|
||||
"input": "223 40% 24%",
|
||||
"ring": "175 85% 42%"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
platform/ui-next/src/themes/deep.json
Normal file
31
platform/ui-next/src/themes/deep.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "deep",
|
||||
"label": "Neutral: Deep",
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"highlight": "184 53% 54%",
|
||||
"neutral": "215 15% 50%",
|
||||
"neutral-light": "210 20% 68%",
|
||||
"neutral-dark": "220 18% 22%",
|
||||
"background": "0 0% 0%",
|
||||
"foreground": "215 15% 82%",
|
||||
"card": "218 25% 5%",
|
||||
"card-foreground": "215 15% 82%",
|
||||
"popover": "215 30% 8%",
|
||||
"popover-foreground": "215 15% 82%",
|
||||
"primary": "200 43% 48%",
|
||||
"primary-foreground": "210 20% 92%",
|
||||
"secondary": "218 30% 18%",
|
||||
"secondary-foreground": "215 25% 75%",
|
||||
"muted": "214 28% 5%",
|
||||
"muted-foreground": "215 18% 48%",
|
||||
"accent": "216 32% 12%",
|
||||
"accent-foreground": "215 15% 82%",
|
||||
"destructive": "0 50% 35%",
|
||||
"destructive-foreground": "0 15% 90%",
|
||||
"border": "218 22% 10%",
|
||||
"input": "216 28% 15%",
|
||||
"ring": "215 45% 42%"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
platform/ui-next/src/themes/index.ts
Normal file
16
platform/ui-next/src/themes/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import orchid from './orchid.json';
|
||||
import arctic from './arctic.json';
|
||||
import verdant from './verdant.json';
|
||||
import midnight from './midnight.json';
|
||||
import slate from './slate.json';
|
||||
import deep from './deep.json';
|
||||
|
||||
export interface ThemePreset {
|
||||
name: string;
|
||||
label: string;
|
||||
cssVars: {
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export const themePresets: ThemePreset[] = [orchid, arctic, verdant, midnight, slate, deep];
|
||||
31
platform/ui-next/src/themes/midnight.json
Normal file
31
platform/ui-next/src/themes/midnight.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "midnight",
|
||||
"label": "Neutral: Midnight",
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"highlight": "188 90% 58%",
|
||||
"neutral": "213 22% 59%",
|
||||
"neutral-light": "214 69% 81%",
|
||||
"neutral-dark": "214 16% 21%",
|
||||
"background": "240 15% 3%",
|
||||
"foreground": "0 0% 99%",
|
||||
"card": "240 12% 8%",
|
||||
"card-foreground": "0 0% 99%",
|
||||
"popover": "225 45% 13%",
|
||||
"popover-foreground": "0 0% 99%",
|
||||
"primary": "210 100% 62%",
|
||||
"primary-foreground": "0 0% 99%",
|
||||
"secondary": "215 55% 32%",
|
||||
"secondary-foreground": "200 60% 88%",
|
||||
"muted": "240 12% 8%",
|
||||
"muted-foreground": "210 40% 70%",
|
||||
"accent": "220 65% 20%",
|
||||
"accent-foreground": "0 0% 99%",
|
||||
"destructive": "0 70% 35%",
|
||||
"destructive-foreground": "0 0% 99%",
|
||||
"border": "240 10% 18%",
|
||||
"input": "225 35% 24%",
|
||||
"ring": "210 100% 62%"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
platform/ui-next/src/themes/orchid.json
Normal file
31
platform/ui-next/src/themes/orchid.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "orchid",
|
||||
"label": "Tonal: Orchid",
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"highlight": "292 75% 62%",
|
||||
"neutral": "270 18% 55%",
|
||||
"neutral-light": "275 35% 75%",
|
||||
"neutral-dark": "268 20% 24%",
|
||||
"background": "270 45% 6%",
|
||||
"foreground": "280 15% 96%",
|
||||
"card": "268 40% 10%",
|
||||
"card-foreground": "280 15% 96%",
|
||||
"popover": "264 48% 13%",
|
||||
"popover-foreground": "280 15% 96%",
|
||||
"primary": "270 85% 65%",
|
||||
"primary-foreground": "0 0% 98%",
|
||||
"secondary": "268 45% 32%",
|
||||
"secondary-foreground": "275 45% 88%",
|
||||
"muted": "268 40% 10%",
|
||||
"muted-foreground": "272 30% 60%",
|
||||
"accent": "268 50% 20%",
|
||||
"accent-foreground": "280 15% 96%",
|
||||
"destructive": "0 65% 40%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "268 30% 18%",
|
||||
"input": "268 40% 25%",
|
||||
"ring": "270 80% 60%"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
platform/ui-next/src/themes/slate.json
Normal file
31
platform/ui-next/src/themes/slate.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "slate",
|
||||
"label": "Neutral: Slate",
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"highlight": "217 97% 52%",
|
||||
"neutral": "0 0% 55%",
|
||||
"neutral-light": "0 0% 75%",
|
||||
"neutral-dark": "0 0% 25%",
|
||||
"background": "0 0% 0%",
|
||||
"foreground": "0 0% 96%",
|
||||
"card": "0 0% 7%",
|
||||
"card-foreground": "0 0% 96%",
|
||||
"popover": "0 0% 9%",
|
||||
"popover-foreground": "0 0% 96%",
|
||||
"primary": "230 75% 55%",
|
||||
"primary-foreground": "0 0% 98%",
|
||||
"secondary": "0 0% 25%",
|
||||
"secondary-foreground": "0 0% 85%",
|
||||
"muted": "0 0% 7%",
|
||||
"muted-foreground": "0 0% 60%",
|
||||
"accent": "0 0% 18%",
|
||||
"accent-foreground": "0 0% 96%",
|
||||
"destructive": "0 65% 40%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "0 0% 18%",
|
||||
"input": "0 0% 22%",
|
||||
"ring": "215 75% 55%"
|
||||
}
|
||||
}
|
||||
}
|
||||
206
platform/ui-next/src/themes/themes.css
Normal file
206
platform/ui-next/src/themes/themes.css
Normal file
@ -0,0 +1,206 @@
|
||||
/* Theme Presets
|
||||
* Complete token sets applied via .theme-* class on <body>.
|
||||
* Default theme ("Tonal: OHIF Blue") = no class (uses :root values from tailwind.css).
|
||||
* Outside @layer base so presets override :root via proximity on <body>.
|
||||
*
|
||||
* Tonal themes: full hue-shifted palettes (surfaces, borders, text all carry the theme hue).
|
||||
* Neutral themes: desaturated/achromatic surfaces with a colored primary accent.
|
||||
*/
|
||||
|
||||
/* ─── Tonal: Orchid ──────────────────────────────────────────────────── */
|
||||
|
||||
.theme-orchid {
|
||||
--highlight: 292 75% 62%;
|
||||
--neutral: 270 18% 55%;
|
||||
--neutral-light: 275 35% 75%;
|
||||
--neutral-dark: 268 20% 24%;
|
||||
--background: 270 45% 6%;
|
||||
--foreground: 280 15% 96%;
|
||||
--card: 268 40% 10%;
|
||||
--card-foreground: 280 15% 96%;
|
||||
--popover: 264 48% 13%;
|
||||
--popover-foreground: 280 15% 96%;
|
||||
--primary: 270 85% 65%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 268 45% 32%;
|
||||
--secondary-foreground: 275 45% 88%;
|
||||
--muted: 268 40% 10%;
|
||||
--muted-foreground: 272 30% 60%;
|
||||
--accent: 268 50% 20%;
|
||||
--accent-foreground: 280 15% 96%;
|
||||
--destructive: 0 65% 40%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 268 30% 18%;
|
||||
--input: 268 40% 25%;
|
||||
--ring: 270 80% 60%;
|
||||
--chart-1: 270 70% 58%;
|
||||
--chart-2: 160 55% 45%;
|
||||
--chart-3: 35 80% 55%;
|
||||
--chart-4: 200 65% 55%;
|
||||
--chart-5: 340 70% 55%;
|
||||
}
|
||||
|
||||
/* ─── Tonal: Arctic ──────────────────────────────────────────────────── */
|
||||
|
||||
.theme-arctic {
|
||||
--highlight: 173 81% 52%;
|
||||
--neutral: 185 20% 55%;
|
||||
--neutral-light: 180 40% 78%;
|
||||
--neutral-dark: 190 18% 22%;
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 180 10% 97%;
|
||||
--card: 203 39% 9%;
|
||||
--card-foreground: 180 10% 97%;
|
||||
--popover: 202 54% 11%;
|
||||
--popover-foreground: 180 10% 97%;
|
||||
--primary: 175 85% 42%;
|
||||
--primary-foreground: 185 50% 8%;
|
||||
--secondary: 180 45% 28%;
|
||||
--secondary-foreground: 175 50% 88%;
|
||||
--muted: 203 39% 9%;
|
||||
--muted-foreground: 185 30% 60%;
|
||||
--accent: 185 55% 18%;
|
||||
--accent-foreground: 180 10% 97%;
|
||||
--destructive: 0 65% 35%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 190 25% 17%;
|
||||
--input: 223 40% 24%;
|
||||
--ring: 175 85% 42%;
|
||||
--chart-1: 175 75% 45%;
|
||||
--chart-2: 140 60% 42%;
|
||||
--chart-3: 35 80% 55%;
|
||||
--chart-4: 280 65% 58%;
|
||||
--chart-5: 330 75% 55%;
|
||||
}
|
||||
|
||||
/* ─── Tonal: Verdant ─────────────────────────────────────────────────── */
|
||||
|
||||
.theme-verdant {
|
||||
--highlight: 152 79% 52%;
|
||||
--neutral: 150 15% 52%;
|
||||
--neutral-light: 145 30% 72%;
|
||||
--neutral-dark: 155 18% 24%;
|
||||
--background: 155 40% 5%;
|
||||
--foreground: 140 15% 95%;
|
||||
--card: 152 35% 9%;
|
||||
--card-foreground: 140 15% 95%;
|
||||
--popover: 167 65% 10%;
|
||||
--popover-foreground: 140 15% 95%;
|
||||
--primary: 152 75% 40%;
|
||||
--primary-foreground: 155 50% 8%;
|
||||
--secondary: 150 40% 26%;
|
||||
--secondary-foreground: 145 40% 85%;
|
||||
--muted: 152 35% 9%;
|
||||
--muted-foreground: 148 25% 55%;
|
||||
--accent: 150 45% 16%;
|
||||
--accent-foreground: 140 15% 95%;
|
||||
--destructive: 0 65% 38%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 150 25% 16%;
|
||||
--input: 150 35% 22%;
|
||||
--ring: 152 75% 40%;
|
||||
--chart-1: 152 70% 42%;
|
||||
--chart-2: 200 60% 48%;
|
||||
--chart-3: 35 80% 55%;
|
||||
--chart-4: 280 60% 55%;
|
||||
--chart-5: 340 70% 52%;
|
||||
}
|
||||
|
||||
/* ─── Neutral: Midnight ──────────────────────────────────────────────── */
|
||||
|
||||
.theme-midnight {
|
||||
--highlight: 188 90% 58%;
|
||||
--neutral: 213 22% 59%;
|
||||
--neutral-light: 214 69% 81%;
|
||||
--neutral-dark: 214 16% 21%;
|
||||
--background: 240 15% 3%;
|
||||
--foreground: 0 0% 99%;
|
||||
--card: 240 12% 8%;
|
||||
--card-foreground: 0 0% 99%;
|
||||
--popover: 225 45% 13%;
|
||||
--popover-foreground: 0 0% 99%;
|
||||
--primary: 210 100% 62%;
|
||||
--primary-foreground: 0 0% 99%;
|
||||
--secondary: 215 55% 32%;
|
||||
--secondary-foreground: 200 60% 88%;
|
||||
--muted: 240 12% 8%;
|
||||
--muted-foreground: 210 40% 70%;
|
||||
--accent: 220 65% 20%;
|
||||
--accent-foreground: 0 0% 99%;
|
||||
--destructive: 0 70% 35%;
|
||||
--destructive-foreground: 0 0% 99%;
|
||||
--border: 240 10% 18%;
|
||||
--input: 225 35% 24%;
|
||||
--ring: 210 100% 62%;
|
||||
--chart-1: 210 85% 55%;
|
||||
--chart-2: 165 70% 42%;
|
||||
--chart-3: 35 85% 52%;
|
||||
--chart-4: 275 70% 58%;
|
||||
--chart-5: 335 80% 52%;
|
||||
}
|
||||
|
||||
/* ─── Neutral: Slate ─────────────────────────────────────────────────── */
|
||||
|
||||
.theme-slate {
|
||||
--highlight: 217 97% 52%;
|
||||
--neutral: 0 0% 55%;
|
||||
--neutral-light: 0 0% 75%;
|
||||
--neutral-dark: 0 0% 25%;
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 96%;
|
||||
--card: 0 0% 7%;
|
||||
--card-foreground: 0 0% 96%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 96%;
|
||||
--primary: 230 75% 55%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 25%;
|
||||
--secondary-foreground: 0 0% 85%;
|
||||
--muted: 0 0% 7%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 0 0% 18%;
|
||||
--accent-foreground: 0 0% 96%;
|
||||
--destructive: 0 65% 40%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 18%;
|
||||
--input: 0 0% 22%;
|
||||
--ring: 215 75% 55%;
|
||||
--chart-1: 215 70% 55%;
|
||||
--chart-2: 160 55% 45%;
|
||||
--chart-3: 35 75% 55%;
|
||||
--chart-4: 280 60% 58%;
|
||||
--chart-5: 340 70% 55%;
|
||||
}
|
||||
|
||||
/* ─── Neutral: Deep ──────────────────────────────────────────────────── */
|
||||
|
||||
.theme-deep {
|
||||
--highlight: 184 53% 54%;
|
||||
--neutral: 215 15% 50%;
|
||||
--neutral-light: 210 20% 68%;
|
||||
--neutral-dark: 220 18% 22%;
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 215 15% 82%;
|
||||
--card: 218 25% 5%;
|
||||
--card-foreground: 215 15% 82%;
|
||||
--popover: 215 30% 8%;
|
||||
--popover-foreground: 215 15% 82%;
|
||||
--primary: 200 43% 48%;
|
||||
--primary-foreground: 210 20% 92%;
|
||||
--secondary: 218 30% 18%;
|
||||
--secondary-foreground: 215 25% 75%;
|
||||
--muted: 214 28% 5%;
|
||||
--muted-foreground: 215 18% 48%;
|
||||
--accent: 216 32% 12%;
|
||||
--accent-foreground: 215 15% 82%;
|
||||
--destructive: 0 50% 35%;
|
||||
--destructive-foreground: 0 15% 90%;
|
||||
--border: 218 22% 10%;
|
||||
--input: 216 28% 15%;
|
||||
--ring: 215 45% 42%;
|
||||
--chart-1: 215 50% 45%;
|
||||
--chart-2: 175 40% 38%;
|
||||
--chart-3: 35 50% 45%;
|
||||
--chart-4: 280 40% 48%;
|
||||
--chart-5: 340 45% 45%;
|
||||
}
|
||||
31
platform/ui-next/src/themes/verdant.json
Normal file
31
platform/ui-next/src/themes/verdant.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "verdant",
|
||||
"label": "Tonal: Verdant",
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"highlight": "152 79% 52%",
|
||||
"neutral": "150 15% 52%",
|
||||
"neutral-light": "145 30% 72%",
|
||||
"neutral-dark": "155 18% 24%",
|
||||
"background": "155 40% 5%",
|
||||
"foreground": "140 15% 95%",
|
||||
"card": "152 35% 9%",
|
||||
"card-foreground": "140 15% 95%",
|
||||
"popover": "167 65% 10%",
|
||||
"popover-foreground": "140 15% 95%",
|
||||
"primary": "152 75% 40%",
|
||||
"primary-foreground": "155 50% 8%",
|
||||
"secondary": "150 40% 26%",
|
||||
"secondary-foreground": "145 40% 85%",
|
||||
"muted": "152 35% 9%",
|
||||
"muted-foreground": "148 25% 55%",
|
||||
"accent": "150 45% 16%",
|
||||
"accent-foreground": "140 15% 95%",
|
||||
"destructive": "0 65% 38%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "150 25% 16%",
|
||||
"input": "150 35% 22%",
|
||||
"ring": "152 75% 40%"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user