diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 7ca24f0a..fd4c642a 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -7,7 +7,7 @@ import { useSyncExternalStore, } from 'react'; import DdocEditor from '../../package/ddoc-editor'; -import type { FontDescriptor } from '../../package/types'; +import type { FontDescriptor, ThemeKey } from '../../package/types'; import { Editor, JSONContent } from '@tiptap/react'; import { SecondLevelNav } from './components/second-level-nav/second-level-nav'; import { demoMenuTree } from './components/second-level-nav/menu-tree'; @@ -37,6 +37,7 @@ import { TagType, DynamicDropdown, ThemeToggle, + useTheme, } from '@fileverse/ui'; import { useMediaQuery } from 'usehooks-ts'; import { IComment } from '../../package/extensions/comment'; @@ -64,6 +65,15 @@ import { DevBar } from './components/DevBar'; import { DocSwitcher } from './components/DocSwitcher'; function App() { + const { theme } = useTheme(); + const [isUltraDark, setIsUltraDark] = useState(false); + const editorTheme: ThemeKey = isUltraDark ? 'theme-ultra-dark' : theme; + + useEffect(() => { + document.documentElement.classList.toggle('theme-ultra-dark', isUltraDark); + return () => document.documentElement.classList.remove('theme-ultra-dark'); + }, [isUltraDark]); + // --- Document identity --- const [docId] = useState(() => { const urlDocId = getDocIdFromURL(); @@ -561,6 +571,13 @@ function App() {
+ {isMediaMax1280px ? ( @@ -887,6 +904,7 @@ function App() { }} onCollaboratorChange={onCollaboratorChange} documentStyling={documentStyling} + theme={editorTheme} isDDocOwner={isDDocOwner} viewerMode={isDDocOwner ? undefined : viewerMode} initialCommentAnchors={initialCommentAnchors} diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx index afb81c2b..cdbe920f 100644 --- a/package/ddoc-editor.tsx +++ b/package/ddoc-editor.tsx @@ -1414,6 +1414,7 @@ const DdocEditor = forwardRef( className={cn( 'w-full', !isPresentationMode ? 'color-bg-secondary' : 'color-bg-default', + theme === 'theme-ultra-dark' && 'theme-ultra-dark', )} style={{ height: isFocusMode diff --git a/package/styles/index.css b/package/styles/index.css index 5d2e805b..b07c8d99 100644 --- a/package/styles/index.css +++ b/package/styles/index.css @@ -116,7 +116,8 @@ --color-editor-neutral-14: #808080; } .dark, -.theme-green { +.theme-green, +.theme-ultra-dark { --color-editor-rose: #ff6161; --color-editor-rose-1: #ff9999; --color-editor-rose-2: #ffd1d1; @@ -223,6 +224,89 @@ --color-editor-neutral-13: #878787; --color-editor-neutral-14: #808080; } + +/* + * Ultra Dark keeps the established dark-theme semantics while lowering the + * document canvas to the #0a0a0a surface used by the hosted dDocs preview. + * The complete token set makes the theme safe to mount without depending on + * an additional `.dark` ancestor. + */ +.theme-ultra-dark { + /* Button */ + --color-button-default: 0, 0%, 20%, 1; + --color-button-default-hover: 0, 0%, 27%, 1; + --color-button-secondary: 0, 0%, 4%, 1; + --color-button-secondary-hover: 0, 0%, 12%, 1; + --color-button-disabled: 0, 0%, 16%, 1; + --color-button-danger: 354, 96%, 59%, 1; + --color-button-danger-hover: 354, 84%, 42%, 1; + --color-button-floating: 0, 0%, 16%, 1; + --color-button-floating-hover: 0, 0%, 22%, 1; + + /* Text */ + --color-text-default: 0, 0%, 91%, 1; + --color-text-secondary: 0, 0%, 65%, 1; + --color-text-disabled: 0, 0%, 46%, 1; + --color-text-danger: 354, 100%, 70%, 1; + --color-text-success: 126, 67%, 45%, 1; + --color-text-inverse: 0, 0%, 91%, 1; + --color-text-link: 251, 100%, 83%, 1; + --color-text-on-brand: 0, 0%, 10%, 1; + + /* Background */ + --color-bg-default: 0, 0%, 4%, 1; /* #0a0a0a */ + --color-bg-default-hover: 0, 0%, 12%, 1; + --color-bg-default-active: 50, 74%, 54%, 1; + --color-bg-default-selected: 0, 0%, 15%, 1; + --color-bg-secondary: 0, 0%, 2%, 1; + --color-bg-secondary-hover: 0, 0%, 10%, 1; + --color-bg-disabled: 0, 0%, 35%, 1; + --color-bg-brand: 50, 74%, 54%, 1; + --color-bg-brand-hover: 50, 60%, 45%, 1; + --color-bg-brand-light: 57, 92%, 76%, 1; + --color-bg-danger: 354, 84%, 42%, 1; + --color-bg-danger-light: 356, 100%, 97%, 1; + --color-bg-default-inverse: 0, 0%, 91%, 1; + --color-bg-tertiary: 0, 0%, 15%, 1; + --color-bg-success: 128, 59%, 20%, 1; + --color-bg-success-light: 128, 59%, 20%, 1; + --color-bg-info-light: 254, 100%, 73%, 0.16; + + /* Border */ + --color-border-default: 0, 0%, 37%, 1; + --color-border-hover: 0, 0%, 46%, 1; + --color-border-light: 0, 0%, 53%, 1; + --color-border-active: 0, 0%, 91%, 1; + --color-border-focused: 254, 100%, 73%, 1; + --color-border-danger: 354, 100%, 70%, 1; + --color-border-warning: 22, 89%, 62%, 1; + --color-border-success: 126, 74%, 73%, 1; + --color-border-info: 254, 100%, 73%, 1; + --color-border-disabled: 0, 0%, 46%, 1; + + /* Icon */ + --color-icon-default: 0, 0%, 100%, 1; + --color-icon-default-hover: 0, 0%, 91%, 1; + --color-icon-brand: 50, 74%, 54%, 1; + --color-icon-secondary: 0, 0%, 65%, 1; + --color-icon-disabled: 0, 0%, 35%, 1; + --color-icon-inverse: 207, 8%, 23%, 1; + --color-icon-danger: 354, 100%, 70%, 1; + --color-icon-warning: 22, 89%, 62%, 1; + --color-icon-info: 254, 100%, 73%, 1; + --color-icon-success: 126, 74%, 73%, 1; + --color-icon-on-brand: 0, 0%, 10%, 1; + + /* Tooltip and comments */ + --color-bg-tooltip: 0, 0%, 91%, 1; + --color-text-tooltip: 0, 0%, 4%, 1; + --color-inline-comment: 128, 59%, 20%, 1; + --color-inline-comment-active: 127, 69%, 29%, 1; + + /* Utility */ + --color-bg-transparent: 0, 0%, 100%, 0.06; + --color-utility-overlay: 0, 0%, 0%, 0.8; +} ul, ol { list-style: revert; diff --git a/package/styles/ultra-dark-theme.test.ts b/package/styles/ultra-dark-theme.test.ts new file mode 100644 index 00000000..fe2cec40 --- /dev/null +++ b/package/styles/ultra-dark-theme.test.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const stylesheet = fs.readFileSync(path.join(__dirname, 'index.css'), 'utf8'); +const ultraDarkBlock = stylesheet + .split('.theme-ultra-dark {') + .at(-1) + ?.split('}')[0]; + +const grayToken = (name: string): number => { + const match = ultraDarkBlock?.match( + new RegExp(`${name}:\\s*0,\\s*0%,\\s*(\\d+)%,\\s*1`), + ); + if (!match) throw new Error(`Missing ${name}`); + return Math.round((Number(match[1]) / 100) * 255); +}; + +const luminance = (channel: number): number => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +}; + +const contrast = (foreground: number, background: number): number => { + const lighter = Math.max(luminance(foreground), luminance(background)); + const darker = Math.min(luminance(foreground), luminance(background)); + return (lighter + 0.05) / (darker + 0.05); +}; + +describe('Ultra Dark theme tokens', () => { + it('defines the hosted preview canvas color', () => { + expect(stylesheet).toMatch( + /\.theme-ultra-dark\s*\{[\s\S]*--color-bg-default:\s*0,\s*0%,\s*4%,\s*1;\s*\/\* #0a0a0a \*\//, + ); + }); + + it('defines readable text and visible borders', () => { + expect(stylesheet).toMatch( + /\.theme-ultra-dark\s*\{[\s\S]*--color-text-default:\s*0,\s*0%,\s*91%,\s*1;/, + ); + expect(stylesheet).toMatch( + /\.theme-ultra-dark\s*\{[\s\S]*--color-border-default:\s*0,\s*0%,\s*37%,\s*1;/, + ); + }); + + it('meets contrast targets for text and component boundaries', () => { + const background = grayToken('--color-bg-default'); + + expect( + contrast(grayToken('--color-text-default'), background), + ).toBeGreaterThanOrEqual(7); + expect( + contrast(grayToken('--color-text-secondary'), background), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrast(grayToken('--color-border-default'), background), + ).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/package/types.ts b/package/types.ts index bd26eb40..e84e8fc7 100644 --- a/package/types.ts +++ b/package/types.ts @@ -98,7 +98,8 @@ export type ThemeKey = | 'dark' | 'theme-sepia' | 'theme-pink' - | 'theme-green'; + | 'theme-green' + | 'theme-ultra-dark'; export interface ThemeVariantValue { light: string; diff --git a/package/utils/colors.ts b/package/utils/colors.ts index 422db088..009d47fd 100644 --- a/package/utils/colors.ts +++ b/package/utils/colors.ts @@ -663,7 +663,11 @@ export const getResponsiveColor = ( try { if (color.startsWith('var(--color-editor-')) return color; const colorObj = Color(color); - if (theme === 'dark' || theme === 'theme-green') { + if ( + theme === 'dark' || + theme === 'theme-green' || + theme === 'theme-ultra-dark' + ) { if (colorObj.isDark()) { if (colorObj.black() === 100) return colorObj.negate().hex(); // if the color is hard black, turn to white return lightenBy(colorObj, 0.5); diff --git a/package/utils/document-styling.test.ts b/package/utils/document-styling.test.ts new file mode 100644 index 00000000..fdefb53d --- /dev/null +++ b/package/utils/document-styling.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { getResponsiveThemeTextColor, getThemeStyle } from './document-styling'; + +describe('Ultra Dark document styling', () => { + it('selects an explicit Ultra Dark variant', () => { + expect( + getThemeStyle( + { + light: '#ffffff', + dark: '#1e1e1e', + 'theme-ultra-dark': '#0a0a0a', + }, + 'theme-ultra-dark', + ), + ).toBe('#0a0a0a'); + }); + + it('falls back to the light variant when Ultra Dark is not provided', () => { + expect( + getThemeStyle({ light: '#ffffff', dark: '#1e1e1e' }, 'theme-ultra-dark'), + ).toBe('#ffffff'); + }); + + it('makes hard black text readable on Ultra Dark surfaces', () => { + expect(getResponsiveThemeTextColor('#000000', 'theme-ultra-dark')).toBe( + '#FFFFFF', + ); + }); +}); diff --git a/tailwind.config.js b/tailwind.config.js index 6bc57446..47b86e93 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ export default { darkMode: ['class'], - safelist: ['theme-sepia', 'theme-pink', 'theme-green'], + safelist: ['theme-sepia', 'theme-pink', 'theme-green', 'theme-ultra-dark'], content: ['./package/**/*.{js,ts,jsx,tsx}'], theme: { extend: {