feat(ui-next): Adds appearance dialog with theme presets and custom theme support (#6041)

This commit is contained in:
Dan Rukas 2026-06-29 12:38:46 -04:00 committed by GitHub
parent 0fcebe05c6
commit 0dfd321326
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 868 additions and 3 deletions

View File

@ -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'),

View File

@ -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,
};

View File

@ -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,

View File

@ -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, [

View File

@ -2,6 +2,9 @@
window.config = {
routerBasename: null,
customizationService: [
'@ohif/extension-default.customizationModule.theme',
],
extensions: [],
modes: [],
showStudyList: true,

View File

@ -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',

View 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"
}

View File

@ -1,5 +1,6 @@
{
"About": "About",
"Appearance": "Appearance",
"Back to Viewer": "Back to Viewer",
"INVESTIGATIONAL USE ONLY": "INVESTIGATIONAL USE ONLY",
"Options": "Options",

View File

@ -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,

View File

@ -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;

View File

@ -1,3 +1,4 @@
export { UserPreferencesModal } from './UserPreferencesModal';
export { ImageModal } from './ImageModal';
export { AboutModal } from './AboutModal';
export { AppearanceModal } from './AppearanceModal';

View File

@ -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,

View 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;
}

View File

@ -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';

View 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%"
}
}
}

View 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%"
}
}
}

View 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];

View 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%"
}
}
}

View 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%"
}
}
}

View 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%"
}
}
}

View 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%;
}

View 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%"
}
}
}