Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,6 +37,7 @@ import {
TagType,
DynamicDropdown,
ThemeToggle,
useTheme,
} from '@fileverse/ui';
import { useMediaQuery } from 'usehooks-ts';
import { IComment } from '../../package/extensions/comment';
Expand Down Expand Up @@ -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<string>(() => {
const urlDocId = getDocIdFromURL();
Expand Down Expand Up @@ -561,6 +571,13 @@ function App() {
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
aria-pressed={isUltraDark}
onClick={() => setIsUltraDark((enabled) => !enabled)}
>
Ultra Dark
</Button>
<ThemeToggle />

{isMediaMax1280px ? (
Expand Down Expand Up @@ -887,6 +904,7 @@ function App() {
}}
onCollaboratorChange={onCollaboratorChange}
documentStyling={documentStyling}
theme={editorTheme}
isDDocOwner={isDDocOwner}
viewerMode={isDDocOwner ? undefined : viewerMode}
initialCommentAnchors={initialCommentAnchors}
Expand Down
1 change: 1 addition & 0 deletions package/ddoc-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 85 additions & 1 deletion package/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions package/styles/ultra-dark-theme.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion package/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ export type ThemeKey =
| 'dark'
| 'theme-sepia'
| 'theme-pink'
| 'theme-green';
| 'theme-green'
| 'theme-ultra-dark';

export interface ThemeVariantValue {
light: string;
Expand Down
6 changes: 5 additions & 1 deletion package/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions package/utils/document-styling.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
2 changes: 1 addition & 1 deletion tailwind.config.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down