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: '
A
'})).toEqual([ + expect(parseClipboardGrid({html: '
A
'})).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: `
A
`})).toEqual([ + [{text: 'A', color: 'green'}], + ]); + }); + + it('preserves browser-serialized translucent export colours', () => { + expect(parseClipboardGrid({html: '
A
'})).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: '
AB
'})!; + const source = parseClipboardGrid({html: '
AB
'})!; 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%); }