diff --git a/packages/ui/src/blockeditor/__tests__/colors.test.ts b/packages/ui/src/blockeditor/__tests__/colors.test.ts
new file mode 100644
index 00000000..05599a2b
--- /dev/null
+++ b/packages/ui/src/blockeditor/__tests__/colors.test.ts
@@ -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'))));
+ });
+ }
+ });
+}
diff --git a/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts b/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts
index ce5d6adb..e90fda65 100644
--- a/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts
+++ b/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts
@@ -22,7 +22,19 @@ describe('parseClipboardGrid', () => {
});
it('preserves an exported cell background token', () => {
- expect(parseClipboardGrid({html: '
'})).toEqual([
+ expect(parseClipboardGrid({html: ''})).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: ``})).toEqual([
+ [{text: 'A', color: 'green'}],
+ ]);
+ });
+
+ it('preserves browser-serialized translucent export colours', () => {
+ expect(parseClipboardGrid({html: ''})).toEqual([
[{text: 'A', color: 'green'}],
]);
});
@@ -102,7 +114,7 @@ describe('tablePasteGrid', () => {
it('applies tint-carrying HTML cells to the destination range', () => {
const doc = seededTable();
- const source = parseClipboardGrid({html: ''})!;
+ const source = parseClipboardGrid({html: ''})!;
tablePasteGrid(doc, 'tbl', {row: 1, col: 1}, source);
const grid = tableGrid(findBlock(doc, 'tbl')!.block);
expect(tableCellOwnColor(grid.cells[1][1]!)).toBe('blue');
diff --git a/packages/ui/src/blockeditor/__tests__/tableSpans.test.ts b/packages/ui/src/blockeditor/__tests__/tableSpans.test.ts
index 746c5b5d..1ac1707b 100644
--- a/packages/ui/src/blockeditor/__tests__/tableSpans.test.ts
+++ b/packages/ui/src/blockeditor/__tests__/tableSpans.test.ts
@@ -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');
});
});
diff --git a/packages/ui/src/blockeditor/colors.ts b/packages/ui/src/blockeditor/colors.ts
index a16489b4..26340b8f 100644
--- a/packages/ui/src/blockeditor/colors.ts
+++ b/packages/ui/src/blockeditor/colors.ts
@@ -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 = {
- 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;
+
+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'},
};
diff --git a/packages/ui/src/blockeditor/tablePaste.ts b/packages/ui/src/blockeditor/tablePaste.ts
index 512c8da2..662bfdd2 100644
--- a/packages/ui/src/blockeditor/tablePaste.ts
+++ b/packages/ui/src/blockeditor/tablePaste.ts
@@ -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 = {'#e5e7eb': 'gray', '#ece0d8': 'brown', '#ffedd5': 'orange', '#fef3c7': 'yellow', '#dcfce7': 'green', '#dbeafe': 'blue', '#f3e8ff': 'purple', '#fce7f3': 'pink', '#fee2e2': 'red'};
+const colorByCss = new Map([...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);
};
diff --git a/packages/ui/src/export/__tests__/export.test.ts b/packages/ui/src/export/__tests__/export.test.ts
index 40586911..841fba4b 100644
--- a/packages/ui/src/export/__tests__/export.test.ts
+++ b/packages/ui/src/export/__tests__/export.test.ts
@@ -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}},
diff --git a/packages/ui/src/export/__tests__/exportReactive.test.ts b/packages/ui/src/export/__tests__/exportReactive.test.ts
index 525c773f..6865ef6e 100644
--- a/packages/ui/src/export/__tests__/exportReactive.test.ts
+++ b/packages/ui/src/export/__tests__/exportReactive.test.ts
@@ -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(/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(/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', () => {
diff --git a/packages/ui/src/export/toHtml.ts b/packages/ui/src/export/toHtml.ts
index 26f31fdb..4c030fb7 100644
--- a/packages/ui/src/export/toHtml.ts
+++ b/packages/ui/src/export/toHtml.ts
@@ -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';
@@ -208,7 +208,8 @@ interface RenderCtx {
* on a dark background (brown/purple especially). So we re-emit text colour as a
* `var(--obtc-, )`: 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]));
@@ -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; }
}
@@ -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; }
@@ -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; }
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
index f81e78ca..c77817fd 100644
--- a/packages/ui/src/index.css
+++ b/packages/ui/src/index.css
@@ -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%); }