From b236073a978bdb845936c8a1a55566d4c71f2d2d Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 08:40:33 +0800 Subject: [PATCH 1/5] fix(ui): export stored warn callouts with warning tint --- packages/ui/src/export/__tests__/export.test.ts | 14 ++++++++++++++ packages/ui/src/export/toHtml.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/export/__tests__/export.test.ts b/packages/ui/src/export/__tests__/export.test.ts index 40586911..6320519e 100644 --- a/packages/ui/src/export/__tests__/export.test.ts +++ b/packages/ui/src/export/__tests__/export.test.ts @@ -146,6 +146,20 @@ describe('toMarkdown', () => { }); }); +describe('stored callout variants', () => { + it.each([ + ['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}"`); + expect(html).toContain(`.callout[data-variant=${variant}] { background: ${tint};`); + expect(html).toContain(`.callout[data-variant=${variant}]::before { content: "${icon}"; }`); + }); +}); + describe('new block types', () => { const blocks = [ {type: 'header', data: {text: 'Alpha', level: 2}}, diff --git a/packages/ui/src/export/toHtml.ts b/packages/ui/src/export/toHtml.ts index 26f31fdb..38c1b1fb 100644 --- a/packages/ui/src/export/toHtml.ts +++ b/packages/ui/src/export/toHtml.ts @@ -1399,11 +1399,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; } From f2438401038a024bb83a39615d1941988db925bb Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 08:43:44 +0800 Subject: [PATCH 2/5] fix(ui): raise palette contrast and synchronize export colors --- .../src/blockeditor/__tests__/colors.test.ts | 76 +++++++++++++++++++ .../blockeditor/__tests__/tablePaste.test.ts | 10 ++- .../blockeditor/__tests__/tableSpans.test.ts | 6 +- packages/ui/src/blockeditor/colors.ts | 39 ++++++---- packages/ui/src/blockeditor/tablePaste.ts | 6 +- .../export/__tests__/exportReactive.test.ts | 12 +-- packages/ui/src/export/toHtml.ts | 6 +- packages/ui/src/index.css | 4 +- 8 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 packages/ui/src/blockeditor/__tests__/colors.test.ts 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..65e0ca20 100644 --- a/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts +++ b/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts @@ -22,7 +22,13 @@ describe('parseClipboardGrid', () => { }); it('preserves an exported cell background token', () => { - expect(parseClipboardGrid({html: '
A
'})).toEqual([ + 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 +108,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..ebd2febe 100644 --- a/packages/ui/src/blockeditor/tablePaste.ts +++ b/packages/ui/src/blockeditor/tablePaste.ts @@ -19,8 +19,10 @@ 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__/exportReactive.test.ts b/packages/ui/src/export/__tests__/exportReactive.test.ts index 525c773f..253a60ed 100644 --- a/packages/ui/src/export/__tests__/exportReactive.test.ts +++ b/packages/ui/src/export/__tests__/exportReactive.test.ts @@ -244,16 +244,16 @@ 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(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 38c1b1fb..1843639d 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'; @@ -1258,9 +1258,7 @@ 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 ')} } /* The ledger alarm red (#b91c1c) goes muddy on the dark page — lighten it. */ .ob-ledger-note.is-alarm { color: #f87171; } 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%); } From 2f54f039b60e9f2817edeea78d75c8b09fb3ca03 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 08:53:59 +0800 Subject: [PATCH 3/5] docs: report DSX-P palette audit results --- _report-dsxp.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 _report-dsxp.md diff --git a/_report-dsxp.md b/_report-dsxp.md new file mode 100644 index 00000000..9a4a69ef --- /dev/null +++ b/_report-dsxp.md @@ -0,0 +1,50 @@ +# DSX-P + +Implemented: stored `warn` callouts get their warning tint/icon; all 18 theme/text pairs clear 4.5:1; all 54 palette export entries mirror CSS. UI: **2,484/2,484 tests pass**. Full verify is not green (SDK timeout); browser failure set matches baseline. No push. + +## Acceptance map + +| Criterion | Commit | Guard / result | +|---|---|---| +| Warn callout export; success/danger drift check | `b236073a` | `export.test.ts`: projected editor fixtures assert matching variant, tint and icon; legacy `warning` alias retained. Baseline regression check: warn fails, success/danger pass; fixed suite 20/20 passes | +| Text contrast in both themes | `f2438401` | `colors.test.ts`: 18 WCAG checks against parsed theme backgrounds | +| 9 × fg/bg/hl × light/dark parity | `f2438401` | `colors.test.ts`: 54 HSL→hex comparisons; conversion reference checks; exportReactive fixture checks emitted colours | +| UI tests, typecheck, ESLint, stylelint | `f2438401` | `pnpm --filter @book.dev/ui run test --maxWorkers=2`: 250 files / 2,484 tests pass; typecheck, ESLint, stylelint + controls pass | +| Foreground `pnpm verify` | both | Exit 1: SDK `money.test.ts` 1e6-round-trip property exceeded 5s. Builds, check:gen, all typechecks/lint passed; SDK 579/580 passed. Isolated unchanged test retry: 27/27 passed. Integration e2e not reached. | +| Viewer/export parity e2e | both | 4 passed / 7 failed, identical failing tests on pre-change `190c5504` baseline and this branch | + +## Visual delta + +Only these CSS values changed; hue/saturation, every background/highlight and all dark CSS values are unchanged. Ratios use unrounded sRGB against `--background` (white / 13% gray). + +| Light text | Old HSL → new HSL | Contrast old → new | +|---|---|---| +| Orange | `28 80% 42%` → `28 80% 39%` | 4.0781 → 4.6324 | +| Yellow | `42 80% 36%` → `42 80% 33%` | 3.9197 → 4.5553 | + +Dark text minimum: red 5.6388:1; no adjustment needed. Light minimum after adjustment: yellow 4.5553:1. + +| Export token | Light fg old → new | Light hl old → new | Dark fg old → new | +|---|---|---|---| +| gray | `#6b7280` → `#737373` | `#e5e7eb` → `#8c8c8c47` | `#9ca3af` → `#a8a8a8` | +| brown | `#92400e` → `#835b3f` | `#ece0d8` → `#bf75404d` | `#c8956b` → `#c59877` | +| orange | `#c2410c` → `#b35e14` | `#ffedd5` → `#f48c2552` | `#fb923c` → `#f0994c` | +| yellow | `#a16207` → `#976f11` | `#fef3c7` → `#f9ce1f66` | `#fcd34d` → `#ecc551` | +| green | `#15803d` → `#2b8248` | `#dcfce7` → `#31c4624d` | `#4ade80` → `#66cc88` | +| blue | `#1d4ed8` → `#2073c5` | `#dbeafe` → `#3994ef47` | `#60a5fa` → `#6cabea` | +| purple | `#7e22ce` → `#7941c8` | `#f3e8ff` → `#8e57db4d` | `#c084fc` → `#b38de7` | +| pink | `#be185d` → `#ca2b7a` | `#fce7f3` → `#e64c994d` | `#f472b6` → `#e981b5` | +| red | `#b91c1c` → `#ce2727` | `#fee2e2` → `#e4444447` | `#f87171` → `#e97777` | + +New bg and dark hl mirrors do not change existing CSS. + +CSS stays authoritative: parsing literals in tests avoids a runtime CSS loader or a generated stylesheet migration before the foundation redesign. Export tables now include bg and dark values. Eight-digit tint hex preserves alpha, including on nested coloured surfaces; channels/alpha round to the nearest byte. Legacy dark text variables derive from the dark table instead of a third hard-coded palette. Clipboard import now recognizes browser-serialized `rgba()` tints, guarded by a round-trip colour fixture. Existing export fixture assertions were updated to the new exact values, not removed. + +Callout change is the stored-variant bug fix; broader callout visual redesign remains with its owning family. The `warning` alias preserves old exports/documents. Manager handles captures: compare orange/yellow editor text, coloured exported text/highlights and warn callouts. + +## Open questions + +Viewer harness is not green at baseline: safe-expression runtime unavailable, sandboxed HTML counter, static expression value, and ledger request assertions fail. Chromium required an approved sandbox escape (macOS bootstrap registration denial). Baseline comparison used an isolated `/tmp/dsxp-baseline` archive, freshly built viewer and regenerated fixtures; no repository tests were weakened. Full verification remains red due to the SDK timeout under load; its unchanged isolated retry passes. No timeout/config was relaxed. Default-concurrency UI run had one ledger property timeout; the complete two-worker run passed all 2,484 tests without changing tests/timeouts. Integration e2e remains unrun because verify stopped at SDK tests. + + +Validation logs: `/tmp/dsxp-verify.log`, `/tmp/dsxp-ui-limited.log`, `/tmp/dsxp-ui-lint.log`, `/tmp/dsxp-viewer-final.log`, `/tmp/dsxp-baseline-viewer.log`, `/tmp/dsxp-sdk-money.log`. Required follow-up: rerun the full gate under lower host load; resolve existing viewer runtime failures separately. From da7a570f3e4373849dbed0bde92adcbd67d6b851 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 08:55:05 +0800 Subject: [PATCH 4/5] chore: untrack worker report artifact --- _report-dsxp.md | 50 ------------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 _report-dsxp.md diff --git a/_report-dsxp.md b/_report-dsxp.md deleted file mode 100644 index 9a4a69ef..00000000 --- a/_report-dsxp.md +++ /dev/null @@ -1,50 +0,0 @@ -# DSX-P - -Implemented: stored `warn` callouts get their warning tint/icon; all 18 theme/text pairs clear 4.5:1; all 54 palette export entries mirror CSS. UI: **2,484/2,484 tests pass**. Full verify is not green (SDK timeout); browser failure set matches baseline. No push. - -## Acceptance map - -| Criterion | Commit | Guard / result | -|---|---|---| -| Warn callout export; success/danger drift check | `b236073a` | `export.test.ts`: projected editor fixtures assert matching variant, tint and icon; legacy `warning` alias retained. Baseline regression check: warn fails, success/danger pass; fixed suite 20/20 passes | -| Text contrast in both themes | `f2438401` | `colors.test.ts`: 18 WCAG checks against parsed theme backgrounds | -| 9 × fg/bg/hl × light/dark parity | `f2438401` | `colors.test.ts`: 54 HSL→hex comparisons; conversion reference checks; exportReactive fixture checks emitted colours | -| UI tests, typecheck, ESLint, stylelint | `f2438401` | `pnpm --filter @book.dev/ui run test --maxWorkers=2`: 250 files / 2,484 tests pass; typecheck, ESLint, stylelint + controls pass | -| Foreground `pnpm verify` | both | Exit 1: SDK `money.test.ts` 1e6-round-trip property exceeded 5s. Builds, check:gen, all typechecks/lint passed; SDK 579/580 passed. Isolated unchanged test retry: 27/27 passed. Integration e2e not reached. | -| Viewer/export parity e2e | both | 4 passed / 7 failed, identical failing tests on pre-change `190c5504` baseline and this branch | - -## Visual delta - -Only these CSS values changed; hue/saturation, every background/highlight and all dark CSS values are unchanged. Ratios use unrounded sRGB against `--background` (white / 13% gray). - -| Light text | Old HSL → new HSL | Contrast old → new | -|---|---|---| -| Orange | `28 80% 42%` → `28 80% 39%` | 4.0781 → 4.6324 | -| Yellow | `42 80% 36%` → `42 80% 33%` | 3.9197 → 4.5553 | - -Dark text minimum: red 5.6388:1; no adjustment needed. Light minimum after adjustment: yellow 4.5553:1. - -| Export token | Light fg old → new | Light hl old → new | Dark fg old → new | -|---|---|---|---| -| gray | `#6b7280` → `#737373` | `#e5e7eb` → `#8c8c8c47` | `#9ca3af` → `#a8a8a8` | -| brown | `#92400e` → `#835b3f` | `#ece0d8` → `#bf75404d` | `#c8956b` → `#c59877` | -| orange | `#c2410c` → `#b35e14` | `#ffedd5` → `#f48c2552` | `#fb923c` → `#f0994c` | -| yellow | `#a16207` → `#976f11` | `#fef3c7` → `#f9ce1f66` | `#fcd34d` → `#ecc551` | -| green | `#15803d` → `#2b8248` | `#dcfce7` → `#31c4624d` | `#4ade80` → `#66cc88` | -| blue | `#1d4ed8` → `#2073c5` | `#dbeafe` → `#3994ef47` | `#60a5fa` → `#6cabea` | -| purple | `#7e22ce` → `#7941c8` | `#f3e8ff` → `#8e57db4d` | `#c084fc` → `#b38de7` | -| pink | `#be185d` → `#ca2b7a` | `#fce7f3` → `#e64c994d` | `#f472b6` → `#e981b5` | -| red | `#b91c1c` → `#ce2727` | `#fee2e2` → `#e4444447` | `#f87171` → `#e97777` | - -New bg and dark hl mirrors do not change existing CSS. - -CSS stays authoritative: parsing literals in tests avoids a runtime CSS loader or a generated stylesheet migration before the foundation redesign. Export tables now include bg and dark values. Eight-digit tint hex preserves alpha, including on nested coloured surfaces; channels/alpha round to the nearest byte. Legacy dark text variables derive from the dark table instead of a third hard-coded palette. Clipboard import now recognizes browser-serialized `rgba()` tints, guarded by a round-trip colour fixture. Existing export fixture assertions were updated to the new exact values, not removed. - -Callout change is the stored-variant bug fix; broader callout visual redesign remains with its owning family. The `warning` alias preserves old exports/documents. Manager handles captures: compare orange/yellow editor text, coloured exported text/highlights and warn callouts. - -## Open questions - -Viewer harness is not green at baseline: safe-expression runtime unavailable, sandboxed HTML counter, static expression value, and ledger request assertions fail. Chromium required an approved sandbox escape (macOS bootstrap registration denial). Baseline comparison used an isolated `/tmp/dsxp-baseline` archive, freshly built viewer and regenerated fixtures; no repository tests were weakened. Full verification remains red due to the SDK timeout under load; its unchanged isolated retry passes. No timeout/config was relaxed. Default-concurrency UI run had one ledger property timeout; the complete two-worker run passed all 2,484 tests without changing tests/timeouts. Integration e2e remains unrun because verify stopped at SDK tests. - - -Validation logs: `/tmp/dsxp-verify.log`, `/tmp/dsxp-ui-limited.log`, `/tmp/dsxp-ui-lint.log`, `/tmp/dsxp-viewer-final.log`, `/tmp/dsxp-baseline-viewer.log`, `/tmp/dsxp-sdk-money.log`. Required follow-up: rerun the full gate under lower host load; resolve existing viewer runtime failures separately. From d57cd4c5f50eaa8f65dfec8029761ed5e61548be Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 09:01:12 +0800 Subject: [PATCH 5/5] fix(ui): preserve palette highlight contrast and legacy paste tokens --- packages/ui/src/blockeditor/__tests__/tablePaste.test.ts | 6 ++++++ packages/ui/src/blockeditor/tablePaste.ts | 4 +++- packages/ui/src/export/__tests__/export.test.ts | 7 +++++-- packages/ui/src/export/__tests__/exportReactive.test.ts | 1 + packages/ui/src/export/toHtml.ts | 9 ++++++--- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts b/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts index 65e0ca20..e90fda65 100644 --- a/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts +++ b/packages/ui/src/blockeditor/__tests__/tablePaste.test.ts @@ -27,6 +27,12 @@ describe('parseClipboardGrid', () => { ]); }); + 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'}], diff --git a/packages/ui/src/blockeditor/tablePaste.ts b/packages/ui/src/blockeditor/tablePaste.ts index ebd2febe..662bfdd2 100644 --- a/packages/ui/src/blockeditor/tablePaste.ts +++ b/packages/ui/src/blockeditor/tablePaste.ts @@ -14,7 +14,9 @@ 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; diff --git a/packages/ui/src/export/__tests__/export.test.ts b/packages/ui/src/export/__tests__/export.test.ts index 6320519e..841fba4b 100644 --- a/packages/ui/src/export/__tests__/export.test.ts +++ b/packages/ui/src/export/__tests__/export.test.ts @@ -148,6 +148,7 @@ 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)', 'πŸ›‘'], @@ -155,8 +156,10 @@ describe('stored callout variants', () => { 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}"`); - expect(html).toContain(`.callout[data-variant=${variant}] { background: ${tint};`); - expect(html).toContain(`.callout[data-variant=${variant}]::before { content: "${icon}"; }`); + 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] {'); }); }); diff --git a/packages/ui/src/export/__tests__/exportReactive.test.ts b/packages/ui/src/export/__tests__/exportReactive.test.ts index 253a60ed..6865ef6e 100644 --- a/packages/ui/src/export/__tests__/exportReactive.test.ts +++ b/packages/ui/src/export/__tests__/exportReactive.test.ts @@ -248,6 +248,7 @@ describe('export block fidelity', () => { 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: #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}); diff --git a/packages/ui/src/export/toHtml.ts b/packages/ui/src/export/toHtml.ts index 1843639d..4c030fb7 100644 --- a/packages/ui/src/export/toHtml.ts +++ b/packages/ui/src/export/toHtml.ts @@ -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])); @@ -1260,6 +1261,8 @@ const SCHEME_DUAL = ` :root { ${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; } } @@ -1280,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; }