Skip to content
Merged
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
76 changes: 76 additions & 0 deletions packages/ui/src/blockeditor/__tests__/colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment node
// @ts-expect-error -- Vitest runs in Node; the browser package omits Node ambient types.
import {readFileSync} from 'node:fs';
import {describe, expect, it} from 'vitest';
import {COLOR_EXPORT_HEX, COLOR_EXPORT_HEX_DARK, COLOR_TOKENS} from '../colors';

const css: string = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
const roles = ['fg', 'bg', 'hl'] as const;

// CSS HSL -> sRGB, retaining alpha so tints also match on nested surfaces.
function rgba(value: string): number[] {
const match = /^(\d+(?:\.\d+)?) (\d+(?:\.\d+)?)% (\d+(?:\.\d+)?)%(?: \/ ([\d.]+))?$/.exec(value);
if (!match) throw new Error(`Unsupported HSL: ${value}`);
const h = Number(match[1]) / 30;
const s = Number(match[2]) / 100;
const l = Number(match[3]) / 100;
const a = s * Math.min(l, 1 - l);
const channel = (n: number) => {
const k = (n + h) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [channel(0), channel(8), channel(4), Number(match[4] ?? 1)];
}

function literal(selector: string, property: string): string {
const rule = css.split('\n').find((line) => line.startsWith(`${selector} {`));
const value = rule?.match(new RegExp(`${property}: hsl\\(([^)]+)\\)`))?.[1];
if (!value) throw new Error(`Missing ${selector} ${property}`);
return value;
}

function hex(channels: number[]): string {
const values = channels[3] === 1 ? channels.slice(0, 3) : channels;
return '#' + values.map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
}

function luminance(channels: number[]): number {
return channels.slice(0, 3).reduce((sum, c, i) =>
sum + [0.2126, 0.7152, 0.0722][i] * (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4), 0);
}

function contrast(a: number[], b: number[]): number {
const values = [luminance(a), luminance(b)];
return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05);
}

it('checks conversion and WCAG reference values', () => {
expect(hex(rgba('0 100% 50%'))).toBe('#ff0000');
expect(hex(rgba('120 100% 50% / 0.5'))).toBe('#00ff0080');
expect(contrast(rgba('0 0% 0%'), rgba('0 0% 100%'))).toBe(21);
expect(contrast(rgba('0 0% 50%'), rgba('0 0% 50%'))).toBe(1);
});

for (const dark of [false, true]) {
const mode = dark ? 'dark' : 'light';
const palette = dark ? COLOR_EXPORT_HEX_DARK : COLOR_EXPORT_HEX;
// Read actual theme backgrounds, not duplicated test constants.
const theme = css.match(dark ? /\.dark\s*\{([^}]+)\}/ : /:root\s*\{([^}]+)\}/)?.[1];
const background = theme?.match(/--background:\s*([^;]+);/)?.[1];
if (!background) throw new Error(`Missing ${mode} background`);
describe(`${mode} editor palette`, () => {
it('covers every palette token', () => {
expect(Object.keys(palette).sort()).toEqual(COLOR_TOKENS.map(({id}) => id).sort());
expect(COLOR_TOKENS).toHaveLength(9);
});
for (const {id} of COLOR_TOKENS) {
const selector = `${dark ? '.dark ' : ''}.obe-`;
it(`${id} text meets 4.5:1`, () => {
expect(contrast(rgba(literal(`${selector}fg-${id}`, 'color')), rgba(background))).toBeGreaterThanOrEqual(4.5);
});
it.each(roles)(`${id} %s matches export hex`, (role) => {
expect(palette[id][role]).toBe(hex(rgba(literal(`${selector}${role}-${id}`, role === 'fg' ? 'color' : 'background-color'))));
});
}
});
}
16 changes: 14 additions & 2 deletions packages/ui/src/blockeditor/__tests__/tablePaste.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,19 @@ describe('parseClipboardGrid', () => {
});

it('preserves an exported cell background token', () => {
expect(parseClipboardGrid({html: '<table><tr><td style="background:#dcfce7">A</td></tr></table>'})).toEqual([
expect(parseClipboardGrid({html: '<table><tr><td style="background:#31c4624d">A</td></tr></table>'})).toEqual([
[{text: 'A', color: 'green'}],
]);
});

it.each(['#dcfce7', 'rgb(220, 252, 231)'])('preserves legacy exported cell background %s as a token', (background) => {
expect(parseClipboardGrid({html: `<table><tr><td style="background:${background}">A</td></tr></table>`})).toEqual([
[{text: 'A', color: 'green'}],
]);
});

it('preserves browser-serialized translucent export colours', () => {
expect(parseClipboardGrid({html: '<table><tr><td style="background:rgba(49, 196, 98, 0.302)">A</td></tr></table>'})).toEqual([
[{text: 'A', color: 'green'}],
]);
});
Expand Down Expand Up @@ -102,7 +114,7 @@ describe('tablePasteGrid', () => {

it('applies tint-carrying HTML cells to the destination range', () => {
const doc = seededTable();
const source = parseClipboardGrid({html: '<table><tr><td style="background:#dbeafe">A</td><td style="background:#fee2e2">B</td></tr></table>'})!;
const source = parseClipboardGrid({html: '<table><tr><td style="background:#3994ef47">A</td><td style="background:#e4444447">B</td></tr></table>'})!;
tablePasteGrid(doc, 'tbl', {row: 1, col: 1}, source);
const grid = tableGrid(findBlock(doc, 'tbl')!.block);
expect(tableCellOwnColor(grid.cells[1][1]!)).toBe('blue');
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/blockeditor/__tests__/tableSpans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,8 @@ describe('TBL-8 HTML export / import', () => {
setTableRowColor(doc, 'tbl', 'row0', 'green');
setTableCellRangeColor(doc, 'tbl', {top: 0, left: 0, bottom: 0, right: 0}, 'red');
const html = cellRangeExportToHtml(tableRangeExport(doc, 'tbl', {top: 0, left: 0, bottom: 1, right: 1}));
expect(html).toContain('background:#fee2e2');
expect(html).toContain('background:#dcfce7');
expect(html).toContain('background:#dbeafe');
expect(html).toContain('background:#e4444447');
expect(html).toContain('background:#31c4624d');
expect(html).toContain('background:#3994ef47');
});
});
39 changes: 26 additions & 13 deletions packages/ui/src/blockeditor/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,31 @@ const IDS = new Set(COLOR_TOKENS.map((c) => c.id));
export const isColorToken = (v: string | undefined | null): v is string => !!v && IDS.has(v);

/**
* Concrete light-theme hex for each token — used by the **exports** (HTML/PDF),
* which are self-contained and can't reference the editor's theme-adaptive CSS
* classes. `fg` colours text (`tc`); `hl` tints a highlight (`hl`).
* Export mirrors of index.css. Eight-digit hex retains tint alpha on any surface.
* colors.test.ts guards every role/theme against CSS; static exports use light.
*/
export const COLOR_EXPORT_HEX: Record<string, {fg: string; hl: string}> = {
gray: {fg: '#6b7280', hl: '#e5e7eb'},
brown: {fg: '#92400e', hl: '#ece0d8'},
orange: {fg: '#c2410c', hl: '#ffedd5'},
yellow: {fg: '#a16207', hl: '#fef3c7'},
green: {fg: '#15803d', hl: '#dcfce7'},
blue: {fg: '#1d4ed8', hl: '#dbeafe'},
purple: {fg: '#7e22ce', hl: '#f3e8ff'},
pink: {fg: '#be185d', hl: '#fce7f3'},
red: {fg: '#b91c1c', hl: '#fee2e2'},
type ExportColors = Record<string, {fg: string; bg: string; hl: string}>;

export const COLOR_EXPORT_HEX: ExportColors = {
gray: {fg: '#737373', bg: '#8080801a', hl: '#8c8c8c47'},
brown: {fg: '#835b3f', bg: '#b9764621', hl: '#bf75404d'},
orange: {fg: '#b35e14', bg: '#ed7e1d21', hl: '#f48c2552'},
yellow: {fg: '#976f11', bg: '#f3bc1626', hl: '#f9ce1f66'},
green: {fg: '#2b8248', bg: '#34b25e21', hl: '#31c4624d'},
blue: {fg: '#2073c5', bg: '#308ce821', hl: '#3994ef47'},
purple: {fg: '#7941c8', bg: '#8954d424', hl: '#8e57db4d'},
pink: {fg: '#ca2b7a', bg: '#df499421', hl: '#e64c994d'},
red: {fg: '#ce2727', bg: '#df3a3a1f', hl: '#e4444447'},
};

export const COLOR_EXPORT_HEX_DARK: ExportColors = {
gray: {fg: '#a8a8a8', bg: '#c7c7c71a', hl: '#9e9e9e4d'},
brown: {fg: '#c59877', bg: '#c084592e', hl: '#b9764657'},
orange: {fg: '#f0994c', bg: '#e886302e', hl: '#e680195c'},
yellow: {fg: '#ecc551', bg: '#e8ba302e', hl: '#e6bd1957'},
green: {fg: '#66cc88', bg: '#40bf6a2e', hl: '#39ac6057'},
blue: {fg: '#6cabea', bg: '#3c8cdd33', hl: '#2f85da5c'},
purple: {fg: '#b38de7', bg: '#9061d138', hl: '#824dcb61'},
pink: {fg: '#e981b5', bg: '#d4549433', hl: '#d1478c5c'},
red: {fg: '#e97777', bg: '#d7424233', hl: '#d435355c'},
};
10 changes: 7 additions & 3 deletions packages/ui/src/blockeditor/tablePaste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ const htmlCellText = (cell: Element): string => {
return (copy.textContent ?? '').trim();
};

const colorByCss = new Map(Object.entries(COLOR_EXPORT_HEX).map(([token, value]) => [value.hl.toLowerCase(), token]));
// Pre-DSX-P opaque export tints, kept so older exported HTML still pastes with its tokens.
const LEGACY_HL: Record<string, string> = {'#e5e7eb': 'gray', '#ece0d8': 'brown', '#ffedd5': 'orange', '#fef3c7': 'yellow', '#dcfce7': 'green', '#dbeafe': 'blue', '#f3e8ff': 'purple', '#fce7f3': 'pink', '#fee2e2': 'red'};
const colorByCss = new Map<string, string>([...Object.entries(LEGACY_HL), ...Object.entries(COLOR_EXPORT_HEX).map(([token, value]) => [value.hl.toLowerCase(), token] as [string, string])]);
const cellColor = (cell: Element): string | undefined => {
const raw = (cell as HTMLElement).style.backgroundColor || (cell as HTMLElement).style.background;
if (!raw) return undefined;
if (colorByCss.has(raw.toLowerCase())) return colorByCss.get(raw.toLowerCase());
const match = raw.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
const hex = match ? `#${match.slice(1).map((n) => Number(n).toString(16).padStart(2, '0')).join('')}` : '';
const match = raw.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
const rgb = match ? match.slice(1, 4).map((n) => Number(n).toString(16).padStart(2, '0')).join('') : '';
const alpha = match?.[4] === undefined ? '' : Math.round(Number(match[4]) * 255).toString(16).padStart(2, '0');
const hex = rgb ? `#${rgb}${alpha}` : '';
return colorByCss.get(hex);
};

Expand Down
17 changes: 17 additions & 0 deletions packages/ui/src/export/__tests__/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ describe('toMarkdown', () => {
});
});

describe('stored callout variants', () => {
it.each([
['info', 'rgba(59,130,246,.10)', '💡'],
['warn', 'rgba(245,158,11,.12)', '⚠️'],
['success', 'rgba(34,197,94,.12)', '✅'],
['danger', 'rgba(239,68,68,.12)', '🛑'],
])('exports %s with its matching tint and icon', (variant, tint, icon) => {
const blocks: BlockJSON[] = [{id: 'callout', type: 'callout', text: [{t: 'Notice'}], props: {variant}}];
const html = toHtml(snapshot(projectBlocksForExport(blocks).blocks), 'T', '');
expect(html).toContain(`data-variant="${variant}"`);
const selector = variant === 'info' ? '.callout' : `.callout[data-variant=${variant}]`;
expect(html).toContain(`${selector} { background: ${tint};`);
expect(html).toContain(`${selector}::before { content: "${icon}"; }`);
expect(html).toContain('.callout[data-variant=warning], .callout[data-variant=warn] {');
});
});

describe('new block types', () => {
const blocks = [
{type: 'header', data: {text: 'Alpha', level: 2}},
Expand Down
13 changes: 7 additions & 6 deletions packages/ui/src/export/__tests__/exportReactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,17 @@ describe('export block fidelity', () => {
// HYDRATE path is light-only v1 (the viewer bundle has no dark theme), so
// the var() just falls back; the brighter dark override is only defined on
// the legacy/no-hydrate path, whose static body honours the OS scheme.
expect(html).toContain('color:var(--obtc-red, #b91c1c)');
expect(html).not.toContain('--obtc-red: #f87171'); // no dark override on the light-only hydrate path
expect(html).toContain('color:var(--obtc-red, #ce2727)');
expect(html).not.toContain('--obtc-red: #e97777'); // no dark override on the light-only hydrate path
const legacy = toHtml({editorjs: {blocks: [{type: 'paragraph', data: {text: 'x'}}]}, values: [], names: []} as never, 'T', '');
expect(legacy).toContain('--obtc-red: #f87171'); // dark-capable legacy path keeps it
expect(html).toMatch(/<mark style="background:#fef3c7">lit<\/mark>/); // yellow highlight tint
expect(legacy).toContain('--obtc-red: #e97777'); // dark-capable legacy path keeps it
expect(legacy).toContain('mark[style] { color: inherit; }');
expect(html).toMatch(/<mark style="background:#f9ce1f66">lit<\/mark>/); // yellow highlight tint
// The document model resolves the run colours so the PDF can use them.
const model = buildDocumentModel({title: 'T', icon: '', snapshot: snap});
const para = model.blocks.find((b) => b.type === 'paragraph') as {runs: Array<{color?: string; markerColor?: string}>};
expect(para.runs.find((r) => r.color)?.color).toBe('#b91c1c');
expect(para.runs.find((r) => r.markerColor)?.markerColor).toBe('#fef3c7');
expect(para.runs.find((r) => r.color)?.color).toBe('#ce2727');
expect(para.runs.find((r) => r.markerColor)?.markerColor).toBe('#f9ce1f66');
});

it('lays columns side-by-side in HTML but flattens them for Markdown', () => {
Expand Down
19 changes: 10 additions & 9 deletions packages/ui/src/export/toHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
renderLedgerReportBlock,
type LedgerExportRecords,
} from './exportLedgerReports';
import {COLOR_EXPORT_HEX} from '../blockeditor/colors';
import {COLOR_EXPORT_HEX, COLOR_EXPORT_HEX_DARK} from '../blockeditor/colors';
import {kitChartRuntime, kitChartSvg} from './kitChart';
import {formatValue} from './format';
import {inlineScriptHash, pageCsp} from './exportCsp';
Expand Down Expand Up @@ -208,7 +208,8 @@ interface RenderCtx {
* on a dark background (brown/purple especially). So we re-emit text colour as a
* `var(--obtc-<token>, <light hex>)`: light mode falls back to the hex, dark mode
* picks up the brighter override defined in `STYLES`. (Highlights need no such
* map — their tints are light pastels in both themes, with forced-dark text.)
* map — their tints are translucent in both themes, with inherited light text
* in dark mode.)
*/
const FG_TOKEN = new Map(Object.entries(COLOR_EXPORT_HEX).map(([token, v]) => [v.fg, token]));

Expand Down Expand Up @@ -1258,10 +1259,10 @@ const SCHEME_DUAL = `
(the light-theme hex go muddy). Inline runs reference these via var(); when
this query is inactive the var() falls back to the baked light hex. */
:root {
--obtc-gray: #9ca3af; --obtc-brown: #c8956b; --obtc-orange: #fb923c;
--obtc-yellow: #fcd34d; --obtc-green: #4ade80; --obtc-blue: #60a5fa;
--obtc-purple: #c084fc; --obtc-pink: #f472b6; --obtc-red: #f87171;
${Object.entries(COLOR_EXPORT_HEX_DARK).map(([token, value]) => `--obtc-${token}: ${value.fg};`).join('\n ')}
}
/* Palette highlight tints are translucent; let marked text inherit the light page ink. */
mark[style] { color: inherit; }
/* The ledger alarm red (#b91c1c) goes muddy on the dark page — lighten it. */
.ob-ledger-note.is-alarm { color: #f87171; }
}
Expand All @@ -1282,8 +1283,8 @@ ul,ol { margin: .4em 0; padding-left: 1.4em; }
blockquote { margin: 1em 0; padding: .2em 0 .2em 1em; border-left: 3px solid currentColor; opacity: .85; font-style: italic; }
pre { background: rgba(127,127,127,.12); padding: 12px 14px; border-radius: 8px; overflow-x: auto; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
/* Highlight tints are always light pastels (in both themes), so the marked text
must stay dark. An inherited colour would be light-on-light (unreadable) in dark mode. */
/* Unstyled highlights keep an opaque tint and dark text. Translucent palette
highlights inherit the light page ink in dark mode via SCHEME_DUAL. */
mark { background: #fde68a; color: #1c1917; padding: 0 .1em; border-radius: 2px; }
hr { border: none; border-top: 1px solid rgba(127,127,127,.3); width: 30%; margin: 2em auto; }
a.mention { font-weight: 600; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; color: inherit; }
Expand Down Expand Up @@ -1399,11 +1400,11 @@ table.db-table a.db-row:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .82em; margin: 1px 2px 1px 0; }
.callout { display: flex; gap: 8px; margin: 8px 0; padding: 12px; border-radius: 8px; border: 1px solid; }
.callout::before { content: "💡"; }
.callout[data-variant=warning]::before { content: "⚠️"; }
.callout[data-variant=warning]::before, .callout[data-variant=warn]::before { content: "⚠️"; }
.callout[data-variant=success]::before { content: "✅"; }
.callout[data-variant=danger]::before { content: "🛑"; }
.callout { background: rgba(59,130,246,.10); border-color: rgba(59,130,246,.35); }
.callout[data-variant=warning] { background: rgba(245,158,11,.12); border-color: rgba(245,158,11,.4); }
.callout[data-variant=warning], .callout[data-variant=warn] { background: rgba(245,158,11,.12); border-color: rgba(245,158,11,.4); }
.callout[data-variant=success] { background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.4); }
.callout[data-variant=danger] { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.4); }
.callout__body { flex: 1; }
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2298,8 +2298,8 @@ a.obe-mention {
/* Text colours (block `fg`, inline `tc`). */
.obe-fg-gray { color: hsl(0 0% 45%); }
.obe-fg-brown { color: hsl(25 35% 38%); }
.obe-fg-orange { color: hsl(28 80% 42%); }
.obe-fg-yellow { color: hsl(42 80% 36%); }
.obe-fg-orange { color: hsl(28 80% 39%); }
.obe-fg-yellow { color: hsl(42 80% 33%); }
.obe-fg-green { color: hsl(140 50% 34%); }
.obe-fg-blue { color: hsl(210 72% 45%); }
.obe-fg-purple { color: hsl(265 55% 52%); }
Expand Down
Loading