diff --git a/apps/staged/src/app.css b/apps/staged/src/app.css index e491cc3d6..5853e7edf 100644 --- a/apps/staged/src/app.css +++ b/apps/staged/src/app.css @@ -90,7 +90,17 @@ --ui-danger-bg: rgba(248, 81, 73, 0.1); --ui-selection: rgba(255, 255, 255, 0.08); - --diagram-canvas-bg: #ffffff; + --diagram-canvas-bg: #f2edf8; + --pikchr-ink: #241a2f; + --pikchr-surface: #fffaff; + --pikchr-muted: #74677f; + --pikchr-red: #ec91a0; + --pikchr-green: #80cd99; + --pikchr-blue: #8bbaed; + --pikchr-yellow: #ecd285; + --pikchr-orange: #ebae7f; + --pikchr-purple: #c4a6f4; + --pikchr-cyan: #80d5e1; --scrollbar-thumb: #47424d; --scrollbar-thumb-hover: #5d5962; diff --git a/apps/staged/src/lib/shared/markdown/diagramRendering.ts b/apps/staged/src/lib/shared/markdown/diagramRendering.ts index 58e259533..21e8581e2 100644 --- a/apps/staged/src/lib/shared/markdown/diagramRendering.ts +++ b/apps/staged/src/lib/shared/markdown/diagramRendering.ts @@ -25,7 +25,7 @@ export function renderMarkdownDiagramCodeBlock( if (!renderedPikchr || renderedPikchr.kind !== 'svg') { return { html: renderedDiagramSource, trustedHtml: false }; } - const renderedSvg = sanitizePikchrSvg(renderedPikchr.svg); + const renderedSvg = sanitizePikchrSvg(renderedPikchr.svg, { source: token.text }); if (!renderedSvg) { return { html: renderedDiagramSource, trustedHtml: false }; } diff --git a/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts b/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts index 4c56dcec9..06dfcb9df 100644 --- a/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts +++ b/apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { loadPikchrRenderer, sanitizePikchrSvg } from './pikchrRendering'; describe('sanitizePikchrSvg', () => { - it('keeps static Pikchr geometry and path styles', () => { + it('keeps static Pikchr geometry and applies the themed default ink', () => { const svg = sanitizePikchrSvg( [ '', @@ -16,9 +16,9 @@ describe('sanitizePikchrSvg', () => { expect(svg).toContain(' { expect(svg).not.toContain('url('); }); + it('maps common Pikchr colors onto the themed palette', () => { + const svg = sanitizePikchrSvg( + [ + '', + '', + '', + 'Label', + '', + ].join('') + ); + + expect(svg).toContain('stroke:var(--pikchr-red)'); + expect(svg).toContain('fill:var(--pikchr-surface)'); + expect(svg).toContain('fill="var(--pikchr-yellow)"'); + expect(svg).toContain('stroke="var(--pikchr-green)"'); + expect(svg).toContain('fill="var(--pikchr-blue)"'); + }); + + it('preserves numeric Pikchr colors from the source', () => { + const svg = sanitizePikchrSvg( + [ + '', + '', + 'Label', + '', + ].join(''), + { + source: 'box "Numeric colors" color 0xff0000 fill 0xffffff', + } + ); + + expect(svg).toContain('stroke:rgb(255,0,0)'); + expect(svg).toContain('fill:rgb(255,255,255)'); + expect(svg).toContain('fill="var(--pikchr-ink)"'); + }); + + it('keeps fill none as no paint', () => { + const svg = sanitizePikchrSvg( + [ + '', + '', + '', + ].join('') + ); + + expect(svg).toContain('fill="none"'); + expect(svg).toContain('stroke="var(--pikchr-ink)"'); + }); + it('adds breathing room to side-anchored Pikchr text labels', () => { const svg = sanitizePikchrSvg( [ @@ -106,6 +155,7 @@ describe('loadPikchrRenderer', () => { expect(rendered.svg).toContain('class="markdown-pikchr-svg"'); expect(rendered.svg).toContain(' MAX_PIKCHR_SVG_LENGTH || !PIKCHR_SVG_ROOT.test(svg)) { return null; } @@ -138,7 +210,7 @@ export function sanitizePikchrSvg(svg: string): string | null { const normalized = sanitized.replace(/\sviewbox=/g, ' viewBox='); if (!PIKCHR_SVG_ROOT.test(normalized)) return null; - return normalized; + return applyThemedPikchrPalette(normalized, options.source); } function normalizePikchrSvgAttributes(tagName: string, attribs: Record) { @@ -183,3 +255,70 @@ function stripUnsafeDirectColorAttributes(tagName: string, attribs: Record { + return `${attribute}="${themePikchrColor(value, preservedColors)}"`; + }) + .replace(/\bstyle="([^"]*)"/gi, (_match, style: string) => { + return `style="${themePikchrStyle(style, preservedColors)}"`; + }); +} + +function themePikchrStyle(style: string, preservedColors: Set): string { + return style.replace( + /(^|;)\s*(fill|stroke)\s*:\s*([^;]+)/gi, + (_match, prefix: string, property: string, value: string) => { + return `${prefix}${property}:${themePikchrColor(value, preservedColors)}`; + } + ); +} + +function themePikchrColor(value: string, preservedColors: Set): string { + const trimmedValue = value.trim(); + const colorKey = canonicalColorKey(trimmedValue); + if (!colorKey || preservedColors.has(colorKey)) return trimmedValue; + + return THEMED_PIKCHR_COLORS.get(colorKey) ?? trimmedValue; +} + +function collectPreservedPikchrColorKeys(source: string | undefined): Set { + const preservedColors = new Set(); + if (!source) return preservedColors; + + const sourceWithoutLabels = source.replace(/"[^"]*(?:"|$)/g, ' '); + for (const match of sourceWithoutLabels.matchAll(PIKCHR_NUMERIC_COLOR_LITERAL)) { + preservedColors.add(`#${match[1].toLowerCase()}`); + } + + return preservedColors; +} + +function canonicalColorKey(value: string): string | null { + const normalizedValue = value.toLowerCase(); + const namedColor = NAMED_PIKCHR_COLOR_KEYS.get(normalizedValue); + if (namedColor) return namedColor; + + const rgb = RGB_COLOR.exec(normalizedValue); + if (rgb) return colorKeyFromRgb(Number(rgb[1]), Number(rgb[2]), Number(rgb[3])); + + const hex = HEX_COLOR.exec(normalizedValue); + if (!hex) return null; + + const digits = hex[1]; + if (digits.length === 3) { + return `#${digits + .split('') + .map((digit) => `${digit}${digit}`) + .join('')}`; + } + return `#${digits}`; +} + +function colorKeyFromRgb(r: number, g: number, b: number): string | null { + if ([r, g, b].some((component) => component < 0 || component > 255)) return null; + + return `#${[r, g, b].map((component) => component.toString(16).padStart(2, '0')).join('')}`; +} diff --git a/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts b/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts index 0e2030726..dba3820d0 100644 --- a/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts +++ b/apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts @@ -42,7 +42,7 @@ describe('renderMarkdown', () => { expect(html).toContain(' { expect(html).not.toContain('STAGED_MARKDOWN_TRUSTED_DIAGRAM_'); }); + it('preserves numeric Pikchr colors through the Markdown rendering path', () => { + const html = renderMarkdown('```pikchr\nbox "Exact" color 0xff0000 fill 0xffffff\n```', { + pikchrRenderer: numericColorPikchrRenderer, + }); + + expect(html).toContain('stroke:rgb(255,0,0)'); + expect(html).toContain('fill:rgb(255,255,255)'); + }); + it('does not allow renderer SVG through the generic Markdown sanitizer', () => { const html = renderMarkdown(''); @@ -101,6 +110,18 @@ const safePikchrRenderer: PikchrRenderer = () => ({ ].join(''), }); +const numericColorPikchrRenderer: PikchrRenderer = () => ({ + kind: 'svg', + width: 58, + height: 34, + svg: [ + '', + '', + 'Exact', + '', + ].join(''), +}); + const unsafePikchrRenderer: PikchrRenderer = () => ({ kind: 'error', message: 'Pikchr rendered unsafe SVG.', diff --git a/apps/staged/src/lib/theme.test.ts b/apps/staged/src/lib/theme.test.ts new file mode 100644 index 000000000..1a9683f3f --- /dev/null +++ b/apps/staged/src/lib/theme.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { createAdaptiveTheme, themeToVarMap } from './theme'; + +describe('createAdaptiveTheme', () => { + it('exposes a soft themed Pikchr palette for dark chrome', () => { + const vars = themeToVarMap( + createAdaptiveTheme('#27212e', '#ffffff', '#91889b', { + added: '#3fb950', + deleted: '#f85149', + modified: '#d29922', + }) + ); + + expect(vars['--diagram-canvas-bg']).toBe('#f2edf8'); + expect(vars['--pikchr-ink']).toBe('#241a2f'); + expect(vars['--pikchr-surface']).toBe('#fffaff'); + expect(vars['--pikchr-muted']).toBe('#74677f'); + expect(vars['--pikchr-red']).toBe('#ec91a0'); + expect(vars['--pikchr-green']).toBe('#80cd99'); + expect(vars['--pikchr-blue']).toBe('#8bbaed'); + expect(vars['--pikchr-yellow']).toBe('#ecd285'); + expect(vars['--pikchr-orange']).toBe('#ebae7f'); + expect(vars['--pikchr-yellow']).not.toBe(vars['--pikchr-orange']); + }); + + it('uses a quiet light Pikchr palette for light chrome', () => { + const vars = themeToVarMap( + createAdaptiveTheme('#ffffff', '#24292e', '#6e7781', { + added: '#28a745', + deleted: '#d73a49', + modified: '#2188ff', + }) + ); + + expect(vars['--diagram-canvas-bg']).toBe('#fbf8ff'); + expect(vars['--pikchr-surface']).toBe('#ffffff'); + expect(vars['--pikchr-ink']).toBe('#24292e'); + expect(vars['--pikchr-blue']).toBe('#81abdd'); + expect(vars['--pikchr-orange']).toBe('#da9a6f'); + expect(vars['--pikchr-yellow']).not.toBe(vars['--pikchr-orange']); + }); +}); diff --git a/apps/staged/src/lib/theme.ts b/apps/staged/src/lib/theme.ts index 5a915d4f1..4136cd0a3 100644 --- a/apps/staged/src/lib/theme.ts +++ b/apps/staged/src/lib/theme.ts @@ -90,7 +90,17 @@ export interface Theme { // Diagram previews diagram: { - canvasBg: string; // Light canvas for rendered diagrams with default dark ink + canvasBg: string; // Canvas behind rendered diagrams + pikchrInk: string; // Default Pikchr stroke/text color + pikchrSurface: string; // Themed replacement for white Pikchr fills + pikchrMuted: string; // Themed replacement for gray/silver Pikchr colors + pikchrRed: string; + pikchrGreen: string; + pikchrBlue: string; + pikchrYellow: string; + pikchrOrange: string; + pikchrPurple: string; + pikchrCyan: string; }; // Scrollbar @@ -255,6 +265,34 @@ const CONTRAST_OFFSET = 0.0135; // mid-gray. Dark themes are unaffected (scale = 1). const LIGHT_CHROME_CONTRAST_SCALE = 0.85; +const DARK_PIKCHR_PALETTE: Theme['diagram'] = { + canvasBg: '#f2edf8', + pikchrInk: '#241a2f', + pikchrSurface: '#fffaff', + pikchrMuted: '#74677f', + pikchrRed: '#ec91a0', + pikchrGreen: '#80cd99', + pikchrBlue: '#8bbaed', + pikchrYellow: '#ecd285', + pikchrOrange: '#ebae7f', + pikchrPurple: '#c4a6f4', + pikchrCyan: '#80d5e1', +}; + +const LIGHT_PIKCHR_PALETTE: Theme['diagram'] = { + canvasBg: '#fbf8ff', + pikchrInk: '#24292e', + pikchrSurface: '#ffffff', + pikchrMuted: '#6e7781', + pikchrRed: '#e18593', + pikchrGreen: '#71b68b', + pikchrBlue: '#81abdd', + pikchrYellow: '#d5b462', + pikchrOrange: '#da9a6f', + pikchrPurple: '#af91e2', + pikchrCyan: '#6abec8', +}; + /** * Calculate target luminance difference using logFloor algorithm. * This provides gentle scaling that works across all theme luminances, @@ -381,6 +419,7 @@ export function createAdaptiveTheme( // Border that's visible but not harsh const borderBase = mix(primaryBg, syntaxFg, isDark ? 0.15 : 0.12); + const pikchrPalette = isDark ? DARK_PIKCHR_PALETTE : LIGHT_PIKCHR_PALETTE; return { isDark, @@ -473,9 +512,7 @@ export function createAdaptiveTheme( selection: overlay(syntaxFg, isDark ? 0.08 : 0.1), }, - diagram: { - canvasBg: '#ffffff', - }, + diagram: pikchrPalette, scrollbar: { thumb: borderBase, @@ -570,6 +607,16 @@ export function themeToVarMap(t: Theme): Record { '--ui-selection': t.ui.selection, '--diagram-canvas-bg': t.diagram.canvasBg, + '--pikchr-ink': t.diagram.pikchrInk, + '--pikchr-surface': t.diagram.pikchrSurface, + '--pikchr-muted': t.diagram.pikchrMuted, + '--pikchr-red': t.diagram.pikchrRed, + '--pikchr-green': t.diagram.pikchrGreen, + '--pikchr-blue': t.diagram.pikchrBlue, + '--pikchr-yellow': t.diagram.pikchrYellow, + '--pikchr-orange': t.diagram.pikchrOrange, + '--pikchr-purple': t.diagram.pikchrPurple, + '--pikchr-cyan': t.diagram.pikchrCyan, '--scrollbar-thumb': t.scrollbar.thumb, '--scrollbar-thumb-hover': t.scrollbar.thumbHover,