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'
|
'ohif.aboutModal'
|
||||||
) as Types.MenuComponentCustomization;
|
) as Types.MenuComponentCustomization;
|
||||||
|
|
||||||
|
const AppearanceModal = customizationService.getCustomization(
|
||||||
|
'ohif.appearanceModal'
|
||||||
|
) as Types.MenuComponentCustomization;
|
||||||
|
|
||||||
const UserPreferencesModal = customizationService.getCustomization(
|
const UserPreferencesModal = customizationService.getCustomization(
|
||||||
'ohif.userPreferencesModal'
|
'ohif.userPreferencesModal'
|
||||||
) as Types.MenuComponentCustomization;
|
) 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) {
|
if (appConfig.oidc) {
|
||||||
menuOptions.push({
|
menuOptions.push({
|
||||||
title: t('Header:Logout'),
|
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 labellingFlowCustomization from './customizations/labellingFlowCustomization';
|
||||||
import viewportNotificationCustomization from './customizations/notificationCustomization';
|
import viewportNotificationCustomization from './customizations/notificationCustomization';
|
||||||
import aboutModalCustomization from './customizations/aboutModalCustomization';
|
import aboutModalCustomization from './customizations/aboutModalCustomization';
|
||||||
|
import appearanceModalCustomization from './customizations/appearanceModalCustomization';
|
||||||
import userPreferencesCustomization from './customizations/userPreferencesCustomization';
|
import userPreferencesCustomization from './customizations/userPreferencesCustomization';
|
||||||
import reportDialogCustomization from './customizations/reportDialogCustomization';
|
import reportDialogCustomization from './customizations/reportDialogCustomization';
|
||||||
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
|
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
|
||||||
@ -39,6 +40,10 @@ export default function getCustomizationModule({ servicesManager, extensionManag
|
|||||||
name: 'helloPage',
|
name: 'helloPage',
|
||||||
value: helloPageCustomization,
|
value: helloPageCustomization,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'theme',
|
||||||
|
value: appearanceModalCustomization,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'datasources',
|
name: 'datasources',
|
||||||
value: datasourcesCustomization,
|
value: datasourcesCustomization,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { DicomMetadataStore, classes } from '@ohif/core';
|
import { DicomMetadataStore, classes } from '@ohif/core';
|
||||||
import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv';
|
import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv';
|
||||||
|
import { ActiveThemeProvider } from '@ohif/ui-next';
|
||||||
|
|
||||||
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
|
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
|
||||||
import { registerHangingProtocolAttributes } from './hangingprotocols';
|
import { registerHangingProtocolAttributes } from './hangingprotocols';
|
||||||
@ -16,7 +17,18 @@ export default function init({
|
|||||||
servicesManager,
|
servicesManager,
|
||||||
commandsManager,
|
commandsManager,
|
||||||
hotkeysManager,
|
hotkeysManager,
|
||||||
|
serviceProvidersManager,
|
||||||
|
appConfig,
|
||||||
}: withAppTypes): void {
|
}: 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;
|
const { toolbarService, cineService, viewportGridService } = servicesManager.services;
|
||||||
|
|
||||||
toolbarService.registerEventForToolbarUpdate(cineService, [
|
toolbarService.registerEventForToolbarUpdate(cineService, [
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
window.config = {
|
window.config = {
|
||||||
routerBasename: null,
|
routerBasename: null,
|
||||||
|
customizationService: [
|
||||||
|
'@ohif/extension-default.customizationModule.theme',
|
||||||
|
],
|
||||||
extensions: [],
|
extensions: [],
|
||||||
modes: [],
|
modes: [],
|
||||||
showStudyList: true,
|
showStudyList: true,
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function defaultSettingsMenuItems({
|
|||||||
const items: SettingsMenuItem[] = [
|
const items: SettingsMenuItem[] = [
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
label: 'About OHIF Viewer',
|
label: t('Header:About'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
|
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
|
||||||
show({
|
show({
|
||||||
@ -42,7 +42,7 @@ export function defaultSettingsMenuItems({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'userPreferences',
|
id: 'userPreferences',
|
||||||
label: 'User Preferences',
|
label: t('Header:Preferences'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const UserPreferencesModal = customizationService.getCustomization(
|
const UserPreferencesModal = customizationService.getCustomization(
|
||||||
'ohif.userPreferencesModal'
|
'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) {
|
if (appConfig.oidc) {
|
||||||
items.push({
|
items.push({
|
||||||
id: 'logout',
|
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",
|
"About": "About",
|
||||||
|
"Appearance": "Appearance",
|
||||||
"Back to Viewer": "Back to Viewer",
|
"Back to Viewer": "Back to Viewer",
|
||||||
"INVESTIGATIONAL USE ONLY": "INVESTIGATIONAL USE ONLY",
|
"INVESTIGATIONAL USE ONLY": "INVESTIGATIONAL USE ONLY",
|
||||||
"Options": "Options",
|
"Options": "Options",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import AboutModal from './AboutModal.json';
|
import AboutModal from './AboutModal.json';
|
||||||
|
import AppearanceModal from './AppearanceModal.json';
|
||||||
import Buttons from './Buttons.json';
|
import Buttons from './Buttons.json';
|
||||||
import CineDialog from './CineDialog.json';
|
import CineDialog from './CineDialog.json';
|
||||||
import Common from './Common.json';
|
import Common from './Common.json';
|
||||||
@ -36,6 +37,7 @@ import USAnnotationPanel from './USAnnotationPanel.json';
|
|||||||
export default {
|
export default {
|
||||||
'en-US': {
|
'en-US': {
|
||||||
AboutModal,
|
AboutModal,
|
||||||
|
AppearanceModal,
|
||||||
Buttons,
|
Buttons,
|
||||||
CineDialog,
|
CineDialog,
|
||||||
Common,
|
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 { UserPreferencesModal } from './UserPreferencesModal';
|
||||||
export { ImageModal } from './ImageModal';
|
export { ImageModal } from './ImageModal';
|
||||||
export { AboutModal } from './AboutModal';
|
export { AboutModal } from './AboutModal';
|
||||||
|
export { AppearanceModal } from './AppearanceModal';
|
||||||
|
|||||||
@ -69,7 +69,7 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tool
|
|||||||
import { ToolboxUI } from './OHIFToolbox';
|
import { ToolboxUI } from './OHIFToolbox';
|
||||||
import Numeric from './Numeric';
|
import Numeric from './Numeric';
|
||||||
import { InputDialog, PresetDialog } from './OHIFDialogs';
|
import { InputDialog, PresetDialog } from './OHIFDialogs';
|
||||||
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
|
import { AboutModal, ImageModal, UserPreferencesModal, AppearanceModal } from './OHIFModals';
|
||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
import { FooterAction } from './FooterAction';
|
import { FooterAction } from './FooterAction';
|
||||||
import { InputFilter } from './InputFilter';
|
import { InputFilter } from './InputFilter';
|
||||||
@ -296,6 +296,7 @@ export {
|
|||||||
AboutModal,
|
AboutModal,
|
||||||
ImageModal,
|
ImageModal,
|
||||||
UserPreferencesModal,
|
UserPreferencesModal,
|
||||||
|
AppearanceModal,
|
||||||
FooterAction,
|
FooterAction,
|
||||||
ToolSettings,
|
ToolSettings,
|
||||||
InputFilter,
|
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 { DragAndDropProvider };
|
||||||
export { CineProvider, useCine };
|
export { CineProvider, useCine };
|
||||||
export { IconPresentationProvider, useIconPresentation } from './IconPresentationProvider';
|
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