From 2e16bf53ef4a0c1bb87e8d89f02b60730d6ab520 Mon Sep 17 00:00:00 2001 From: dani-polani Date: Sat, 11 Jul 2026 18:12:22 +0300 Subject: [PATCH] feat(canvas): independent background selection + custom color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple the canvas from the visual style so any style can render on any background. The Style panel's Canvas section is now a background grid for every style (light, dark + the 12 style canvases), not just Classic, plus a Custom tile painting a solid user-chosen color. - Extract canvases into a background axis (BackgroundId, resolveCanvas); the style still sets a default and picking a style clears the override, the same way palette already works. - custom canvas: native color picker, text color + light/dark chrome derived from luminance; the color is remembered and travels in the share link only while custom is active. - Additive backgroundId / backgroundCustomColor settings — old share links render exactly as before. - Export (PNG/SVG/PDF/HTML) mirrors the chosen background. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../preview/AlignmentPreview.svelte | 26 ++- .../lib/components/preview/StylePicker.svelte | 3 +- .../lib/components/preview/TokenView.svelte | 24 ++- .../lib/components/share/ExportMenu.svelte | 35 +++- .../components/shell/panels/StylePanel.svelte | 91 ++++++++-- bitext/src/lib/domain/styles.test.ts | 102 ++++++++++- bitext/src/lib/domain/styles.ts | 158 +++++++++++++++++- bitext/src/lib/export/svg.test.ts | 91 ++++++++++ bitext/src/lib/export/svg.ts | 26 ++- bitext/src/lib/serialization/compact-v4.ts | 6 + bitext/src/lib/serialization/schema.ts | 15 +- .../serialization-roundtrip.test.ts | 29 ++++ 12 files changed, 553 insertions(+), 53 deletions(-) diff --git a/bitext/src/lib/components/preview/AlignmentPreview.svelte b/bitext/src/lib/components/preview/AlignmentPreview.svelte index fc74534..87a5945 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -11,7 +11,12 @@ import { selectionStore } from '$lib/state/selection.svelte.js'; import { layoutExportStore } from '$lib/state/layoutExport.svelte.js'; import { lineIsLinkTargetWhilePending } from '$lib/domain/lines-helpers.js'; - import { getStyle, effectiveLineFamily } from '$lib/domain/styles.js'; + import { + getStyle, + effectiveLineFamily, + resolveCanvas, + DEFAULT_CUSTOM_BACKGROUND + } from '$lib/domain/styles.js'; import { computeAutoFitScales, scalesChanged, @@ -46,10 +51,19 @@ let rootEl = $state(null); - const bg = $derived(settingsStore.settings.background); const style = $derived(getStyle(settingsStore.settings.style)); - const isClassicStyle = $derived(style.id === 'classic'); - const previewDark = $derived(isClassicStyle ? bg === 'dark' : style.canvas.isDark); + const resolvedCanvas = $derived( + resolveCanvas( + settingsStore.settings.style, + settingsStore.settings.backgroundId, + settingsStore.settings.background === 'dark', + settingsStore.settings.backgroundCustomColor ?? DEFAULT_CUSTOM_BACKGROUND + ) + ); + const canvas = $derived(resolvedCanvas.canvas); + // Plain light/dark canvases render through the preview-frame CSS classes; the rest inline. + const plainCanvas = $derived(resolvedCanvas.plain); + const previewDark = $derived(canvas.isDark); function lineFontCss(line: LineV2): string { return `"${effectiveLineFamily(line, style)}", sans-serif`; @@ -271,8 +285,8 @@ class:preview-frame--dark={previewDark} data-aligner-style={style.id} data-autofit={autoFit ? 'on' : 'off'} - style:background={isClassicStyle ? undefined : style.canvas.previewBackground} - style:color={isClassicStyle ? undefined : style.canvas.textColor} + style:background={plainCanvas ? undefined : canvas.previewBackground} + style:color={plainCanvas ? undefined : canvas.textColor} style:touch-action={zoom.z > 1 ? 'none' : 'pan-y'} onpointerdown={onPointerDown} onpointermove={onPointerMove} diff --git a/bitext/src/lib/components/preview/StylePicker.svelte b/bitext/src/lib/components/preview/StylePicker.svelte index 3a87227..e40ed27 100644 --- a/bitext/src/lib/components/preview/StylePicker.svelte +++ b/bitext/src/lib/components/preview/StylePicker.svelte @@ -29,7 +29,8 @@ function choose(id: StyleId) { const style = getStyle(id); - settingsStore.patch({ style: id }); + // Clear any canvas override so the new style shows on its own default background. + settingsStore.patch({ style: id, backgroundId: undefined }); // Picking a style bundles its palette; the user can change it afterwards. if (style.palette) { settingsStore.patch({ palette: style.palette }); diff --git a/bitext/src/lib/components/preview/TokenView.svelte b/bitext/src/lib/components/preview/TokenView.svelte index 615a43f..29c6cd8 100644 --- a/bitext/src/lib/components/preview/TokenView.svelte +++ b/bitext/src/lib/components/preview/TokenView.svelte @@ -4,7 +4,13 @@ import { settingsStore } from '$lib/state/settings.svelte.js'; import { projectStore } from '$lib/state/project.svelte.js'; import { selectionStore } from '$lib/state/selection.svelte.js'; - import { getStyle, readableTextOn, shiftHue } from '$lib/domain/styles.js'; + import { + getStyle, + readableTextOn, + shiftHue, + resolveCanvas, + DEFAULT_CUSTOM_BACKGROUND + } from '$lib/domain/styles.js'; let { token, @@ -39,11 +45,15 @@ settingsStore.settings.tokenLinkColorMode === 'background' ); - const previewSurfaceHex = $derived.by(() => { - const style = getStyle(settingsStore.settings.style); - if (style.id !== 'classic') return style.canvas.tintBaseHex; - return settingsStore.settings.background === 'dark' ? '#1e1e1e' : '#ffffff'; - }); + const effectiveCanvas = $derived( + resolveCanvas( + settingsStore.settings.style, + settingsStore.settings.backgroundId, + settingsStore.settings.background === 'dark', + settingsStore.settings.backgroundCustomColor ?? DEFAULT_CUSTOM_BACKGROUND + ).canvas + ); + const previewSurfaceHex = $derived(effectiveCanvas.tintBaseHex); const textColor = $derived.by(() => { if (!settingsStore.settings.colorTokensByLink || !conn?.color || linkBgMode) return null; @@ -105,7 +115,7 @@ // Neon glow (own color) or Riso misregistration (hard offset in a shifted spot color). const tokenTextShadow = $derived.by(() => { if (chip) return undefined; - const base = displayColor ?? textColor ?? style.canvas.textColor; + const base = displayColor ?? textColor ?? effectiveCanvas.textColor; if (style.glowText) return `0 0 0.5em ${base}`; if (style.textOffsetShadow) { const { dx, dy } = style.textOffsetShadow; diff --git a/bitext/src/lib/components/share/ExportMenu.svelte b/bitext/src/lib/components/share/ExportMenu.svelte index 6619bc1..79938fc 100644 --- a/bitext/src/lib/components/share/ExportMenu.svelte +++ b/bitext/src/lib/components/share/ExportMenu.svelte @@ -14,7 +14,14 @@ import { settingsStore } from '$lib/state/settings.svelte.js'; import { layoutExportStore } from '$lib/state/layoutExport.svelte.js'; import { googleFontUrlsForLines, svgFontFamilyStackLine } from '$lib/fonts/visualization-font.js'; - import { applyStyleFont, getStyle } from '$lib/domain/styles.js'; + import { + applyStyleFont, + getStyle, + getBackground, + resolveBackgroundId, + computeSolidCanvas, + DEFAULT_CUSTOM_BACKGROUND + } from '$lib/domain/styles.js'; import { buildInlinedFontCssFromLines } from '$lib/fonts/inline-fonts.js'; import { ensureVisualizationCustomFontsFromLines } from '$lib/fonts/ensure-document-fonts.js'; import { convertCustomFontTextToPaths } from '$lib/fonts/text-to-paths.js'; @@ -72,16 +79,29 @@ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); } + function effectiveBackgroundId() { + const s = settingsStore.settings; + return resolveBackgroundId(s.style, s.backgroundId, s.background === 'dark'); + } + + function customColor(): string { + return settingsStore.settings.backgroundCustomColor ?? DEFAULT_CUSTOM_BACKGROUND; + } + function exportTextColor(): string { - const bg = settingsStore.settings.background; - if (bg === 'dark') return '#f8fafc'; - return '#0f172a'; + const id = effectiveBackgroundId(); + if (id === 'dark') return '#f8fafc'; + if (id === 'light') return '#0f172a'; + if (id === 'custom') return computeSolidCanvas(customColor()).textColor; + return getBackground(id).canvas.textColor; } function exportBackgroundColor(): string { - const bg = settingsStore.settings.background; - if (bg === 'dark') return '#1e1e1e'; - return '#ffffff'; + const id = effectiveBackgroundId(); + if (id === 'dark') return '#1e1e1e'; + if (id === 'light') return '#ffffff'; + if (id === 'custom') return customColor(); + return getBackground(id).canvas.previewBackground; } /** File name seeded from the first non-empty line, plus the preset id, e.g. `al-hello-world-square.png`. */ @@ -126,6 +146,7 @@ backgroundColor: exportBackgroundColor(), defaultTextColor: exportTextColor(), style: s.style, + backgroundId: effectiveBackgroundId(), colorTokensByLink: s.colorTokensByLink, tokenLinkColorMode: s.tokenLinkColorMode, lineStyle: s.lineStyle, diff --git a/bitext/src/lib/components/shell/panels/StylePanel.svelte b/bitext/src/lib/components/shell/panels/StylePanel.svelte index fac5583..6bd14ef 100644 --- a/bitext/src/lib/components/shell/panels/StylePanel.svelte +++ b/bitext/src/lib/components/shell/panels/StylePanel.svelte @@ -3,11 +3,23 @@ import SegmentedControl from '$lib/components/settings/SegmentedControl.svelte'; import FontsTab from '$lib/components/settings/FontsTab.svelte'; import { PALETTES, PALETTE_NAMES, type PaletteName } from '$lib/domain/palettes.js'; + import { + BACKGROUNDS_LIST, + resolveBackgroundId, + DEFAULT_CUSTOM_BACKGROUND + } from '$lib/domain/styles.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; import { projectStore } from '$lib/state/project.svelte.js'; const s = $derived(settingsStore.settings); - const isClassic = $derived(s.style === 'classic'); + const currentBackgroundId = $derived( + resolveBackgroundId(s.style, s.backgroundId, s.background === 'dark') + ); + const customColor = $derived(s.backgroundCustomColor ?? DEFAULT_CUSTOM_BACKGROUND); + + function chooseCustomColor(color: string) { + settingsStore.patch({ backgroundId: 'custom', backgroundCustomColor: color }); + } function choosePalette(name: PaletteName) { settingsStore.patch({ palette: name }); @@ -24,21 +36,52 @@
- - {#if isClassic} -
-

Canvas

- settingsStore.patch({ background: v as 'light' | 'dark' })} - /> -
- {/if} + +
+

Canvas

+
+ {#each BACKGROUNDS_LIST as b (b.id)} + {@const selected = currentBackgroundId === b.id} + + {/each} + +
+

+ Picking a style resets this. Choose a background to keep a style's words and links on another + canvas. +

+
@@ -207,3 +250,19 @@
+ + diff --git a/bitext/src/lib/domain/styles.test.ts b/bitext/src/lib/domain/styles.test.ts index 4df757b..4bb7ea2 100644 --- a/bitext/src/lib/domain/styles.test.ts +++ b/bitext/src/lib/domain/styles.test.ts @@ -9,7 +9,16 @@ import { shiftHue, styleExportBackground, styleExportFrame, - STYLE_ORDER + STYLE_ORDER, + BACKGROUNDS_LIST, + BACKGROUND_ORDER, + backgroundExport, + getBackground, + isBackgroundId, + isPlainBackground, + resolveBackgroundId, + resolveCanvas, + computeSolidCanvas } from './styles.js'; import { isPaletteName, PALETTE_NAMES, PALETTES } from './palettes.js'; import { ribbonPathD } from './link-geometry.js'; @@ -97,6 +106,97 @@ describe('style export background', () => { }); }); +describe('background catalog', () => { + it('lists light/dark first, then every themed style (Classic excluded)', () => { + expect(BACKGROUND_ORDER.slice(0, 2)).toEqual(['light', 'dark']); + expect(BACKGROUND_ORDER).not.toContain('classic'); + for (const id of STYLE_ORDER) { + if (id === 'classic') continue; + expect(BACKGROUND_ORDER).toContain(id); + } + expect(BACKGROUNDS_LIST).toHaveLength(BACKGROUND_ORDER.length); + }); + + it('themed backgrounds reuse the style canvas', () => { + expect(getBackground('aurora').canvas).toEqual(getStyle('aurora').canvas); + }); + + it('isBackgroundId guards unknown values', () => { + expect(isBackgroundId('aurora')).toBe(true); + expect(isBackgroundId('light')).toBe(true); + expect(isBackgroundId('classic')).toBe(false); + expect(isBackgroundId('nope')).toBe(false); + }); + + it('isPlainBackground only for light/dark', () => { + expect(isPlainBackground('light')).toBe(true); + expect(isPlainBackground('dark')).toBe(true); + expect(isPlainBackground('aurora')).toBe(false); + }); +}); + +describe('resolveBackgroundId', () => { + it('an explicit override always wins', () => { + expect(resolveBackgroundId('bauhaus', 'aurora', false)).toBe('aurora'); + expect(resolveBackgroundId('classic', 'synthwave', true)).toBe('synthwave'); + }); + + it('classic without override follows the legacy light/dark toggle', () => { + expect(resolveBackgroundId('classic', undefined, false)).toBe('light'); + expect(resolveBackgroundId('classic', undefined, true)).toBe('dark'); + }); + + it('a themed style without override uses its own canvas', () => { + expect(resolveBackgroundId('aurora', undefined, true)).toBe('aurora'); + }); +}); + +describe('backgroundExport', () => { + it('returns null for the plain light/dark canvases', () => { + expect(backgroundExport('light', 0, 0, 100, 100)).toBeNull(); + expect(backgroundExport('dark', 0, 0, 100, 100)).toBeNull(); + }); + + it('paints a themed canvas regardless of the style', () => { + expect(backgroundExport('aurora', 0, 0, 100, 100)?.rect).toContain('url(#bg-aurora)'); + }); + + it('returns null for the custom solid canvas (caller fills it)', () => { + expect(backgroundExport('custom', 0, 0, 100, 100)).toBeNull(); + }); +}); + +describe('custom canvas', () => { + it('isBackgroundId accepts custom; it is not a plain (CSS-class) canvas', () => { + expect(isBackgroundId('custom')).toBe(true); + expect(isPlainBackground('custom')).toBe(false); + }); + + it('computeSolidCanvas derives readable text + isDark from luminance', () => { + const dark = computeSolidCanvas('#101020'); + expect(dark.isDark).toBe(true); + expect(dark.textColor).toBe('#ffffff'); + expect(dark.previewBackground).toBe('#101020'); + + const light = computeSolidCanvas('#fef3c7'); + expect(light.isDark).toBe(false); + expect(light.textColor).toBe('#171008'); + }); + + it('resolveCanvas builds the custom canvas from the chosen color', () => { + const r = resolveCanvas('bauhaus', 'custom', false, '#0055ff'); + expect(r.id).toBe('custom'); + expect(r.plain).toBe(false); + expect(r.canvas.previewBackground).toBe('#0055ff'); + }); + + it('resolveCanvas falls back to the style default when no override', () => { + const r = resolveCanvas('aurora', undefined, false, '#0055ff'); + expect(r.id).toBe('aurora'); + expect(r.canvas).toEqual(getStyle('aurora').canvas); + }); +}); + describe('connector + chip helpers', () => { it('ink styles override the link color, others keep it', () => { expect(connectorColor(getStyle('bauhaus'), '#ef4444')).toBe('#171008'); // ink override diff --git a/bitext/src/lib/domain/styles.ts b/bitext/src/lib/domain/styles.ts index a0841a1..80ea50b 100644 --- a/bitext/src/lib/domain/styles.ts +++ b/bitext/src/lib/domain/styles.ts @@ -63,6 +63,38 @@ export interface StyleCanvas { tintBaseHex: string; } +/** + * A background is a canvas selectable independently of the style. `light`/`dark` are the + * plain canvases (rendered via the preview-frame CSS classes); every other id reuses a + * style's own canvas. Picking a style resets the background to its default; the user can + * then override it with any entry here (e.g. Bauhaus chips on the Aurora canvas). + */ +export type BackgroundId = + | 'light' + | 'dark' + | 'aurora' + | 'atlas' + | 'synthwave' + | 'parchment' + | 'bauhaus' + | 'sumi' + | 'blueprint' + | 'deco' + | 'nouveau' + | 'riso' + | 'spectrum' + // A solid canvas painted in a user-chosen color; its canvas is computed, not catalogued. + | 'custom'; + +export interface Background { + id: BackgroundId; + label: string; + canvas: StyleCanvas; +} + +/** Initial swatch for the custom canvas before the user has picked a color. */ +export const DEFAULT_CUSTOM_BACKGROUND = '#4f46e5'; + export interface VisualStyle { id: StyleId; label: string; @@ -302,6 +334,106 @@ export function getStyle(id: StyleId): VisualStyle { export const STYLES_LIST: VisualStyle[] = STYLE_ORDER.map((id) => STYLES[id]); +/** Plain canvases mirroring the `preview-frame--light/--dark` CSS (the original classic look). */ +const PLAIN_BACKGROUNDS: Record<'light' | 'dark', Background> = { + light: { + id: 'light', + label: 'Light', + canvas: { + isDark: false, + previewBackground: '#ffffff', + textColor: '#0f172a', + tintBaseHex: '#ffffff' + } + }, + dark: { + id: 'dark', + label: 'Dark', + canvas: { + isDark: true, + previewBackground: '#1e1e1e', + textColor: '#e8e8e8', + tintBaseHex: '#1e1e1e' + } + } +}; + +/** `light`/`dark` first, then every themed style's own canvas (Classic excluded — it is `light`/`dark`). */ +export const BACKGROUND_ORDER: BackgroundId[] = [ + 'light', + 'dark', + ...STYLE_ORDER.filter((id) => id !== 'classic') +] as BackgroundId[]; + +const BACKGROUNDS: Record = (() => { + const out = { light: PLAIN_BACKGROUNDS.light, dark: PLAIN_BACKGROUNDS.dark } as Record< + BackgroundId, + Background + >; + for (const id of STYLE_ORDER) { + if (id === 'classic') continue; + out[id] = { id, label: STYLES[id].label, canvas: STYLES[id].canvas }; + } + return out; +})(); + +export const BACKGROUNDS_LIST: Background[] = BACKGROUND_ORDER.map((id) => BACKGROUNDS[id]); + +export function isBackgroundId(v: unknown): v is BackgroundId { + return typeof v === 'string' && (v in BACKGROUNDS || v === 'custom'); +} + +export function getBackground(id: BackgroundId): Background { + return BACKGROUNDS[id]; +} + +/** `light`/`dark` render through the preview-frame CSS classes; themed ids are inlined. */ +export function isPlainBackground(id: BackgroundId): boolean { + return id === 'light' || id === 'dark'; +} + +/** + * The background actually shown: an explicit override wins; otherwise the style's default + * (Classic follows the legacy light/dark toggle, every themed style uses its own canvas). + * Leaving `backgroundId` unset reproduces the pre-feature rendering, so old links are unchanged. + */ +export function resolveBackgroundId( + styleId: StyleId, + backgroundId: BackgroundId | undefined, + legacyDark: boolean +): BackgroundId { + if (backgroundId) return backgroundId; + if (styleId === 'classic') return legacyDark ? 'dark' : 'light'; + return styleId as BackgroundId; +} + +/** Canvas for a solid user color: readable text + dark/light chrome are derived from luminance. */ +export function computeSolidCanvas(color: string): StyleCanvas { + const textColor = readableTextOn(color); + return { + isDark: textColor !== '#171008', + previewBackground: color, + textColor, + tintBaseHex: color + }; +} + +/** + * The effective canvas plus whether it renders through the plain preview-frame CSS classes + * (`light`/`dark` only) or inline. `custom` is computed from `customColor`; every other id + * comes from the catalog. + */ +export function resolveCanvas( + styleId: StyleId, + backgroundId: BackgroundId | undefined, + legacyDark: boolean, + customColor: string +): { id: BackgroundId; canvas: StyleCanvas; plain: boolean } { + const id = resolveBackgroundId(styleId, backgroundId, legacyDark); + if (id === 'custom') return { id, canvas: computeSolidCanvas(customColor), plain: false }; + return { id, canvas: getBackground(id).canvas, plain: isPlainBackground(id) }; +} + /** Connector color under a style: the style ink overrides the per-link palette color. */ export function connectorColor(style: VisualStyle, linkColor: string): string { return style.connector.lineColor ?? linkColor; @@ -378,19 +510,20 @@ export function effectiveLineFamily(line: LineV2, style: VisualStyle): string { } /** - * Background defs + rect(s) for the standalone SVG export. Returns null for `classic`, - * which keeps the caller-provided light/dark fill. Mirrors `canvas.previewBackground`. + * Background defs + rect(s) for the standalone SVG export. Returns null for the plain + * `light`/`dark` canvases, which keep the caller-provided fill. Mirrors `canvas.previewBackground`. */ -export function styleExportBackground( - style: VisualStyle, +export function backgroundExport( + id: BackgroundId, x: number, y: number, width: number, height: number ): { defs: string; rect: string } | null { - if (style.id === 'classic') return null; + // Plain light/dark and the custom solid color are filled from the caller-provided color. + if (isPlainBackground(id) || id === 'custom') return null; const box = `x="${x}" y="${y}" width="${width}" height="${height}"`; - switch (style.id) { + switch (id) { case 'aurora': return { defs: ``, @@ -433,10 +566,21 @@ export function styleExportBackground( rect: `` }; default: - return { defs: '', rect: `` }; + return { defs: '', rect: `` }; } } +/** Style-keyed wrapper: a style's own canvas maps to a background of the same id (`classic` → plain). */ +export function styleExportBackground( + style: VisualStyle, + x: number, + y: number, + width: number, + height: number +): { defs: string; rect: string } | null { + return backgroundExport(style.id === 'classic' ? 'light' : style.id, x, y, width, height); +} + /** Decorative frame for the export, drawn over the background and under the tokens. `''` when none. */ export function styleExportFrame( style: VisualStyle, diff --git a/bitext/src/lib/export/svg.test.ts b/bitext/src/lib/export/svg.test.ts index 7aa153f..20c206e 100644 --- a/bitext/src/lib/export/svg.test.ts +++ b/bitext/src/lib/export/svg.test.ts @@ -72,3 +72,94 @@ describe('buildStandaloneSvgString — visual style', () => { expect(svg).toContain('stroke="#171008"'); }); }); + +describe('buildStandaloneSvgString — independent background', () => { + function buildWith( + style: Parameters[0]['style'], + backgroundId: Parameters[0]['backgroundId'] + ): string { + return buildStandaloneSvgString({ + width: 200, + height: 120, + backgroundColor: '#ffffff', + defaultTextColor: '#0f172a', + style, + backgroundId, + colorTokensByLink: true, + lineStyle: 'curved', + lineThickness: 3, + lineOpacity: 1, + lineOrder: ['s', 't'], + lines: [ + { + lineId: 's', + tokens: [tok('s-0', 'Hi')], + fontFamilyStack: 'Inter, sans-serif', + textSizePx: 36 + }, + { + lineId: 't', + tokens: [tok('t-0', 'Salut')], + fontFamilyStack: 'Inter, sans-serif', + textSizePx: 36 + } + ], + tokenLayout: { 's-0': box(20, 20), 't-0': box(20, 80) }, + connections, + pairControls: [], + includeAttributionFooter: false + }); + } + + it('paints the chosen canvas under a different style (Bauhaus chips on Aurora)', () => { + const svg = buildWith('bauhaus', 'aurora'); + // Aurora canvas from the override… + expect(svg).toContain('url(#bg-aurora)'); + expect(svg).not.toContain('#fddb72'); // not Bauhaus's own yellow fill + // …while the Bauhaus chip shadow (style-owned treatment) is still drawn. + expect(svg).toContain('fill="#171008"'); + }); + + it('a plain background falls back to the caller-provided fill', () => { + const svg = buildWith('aurora', 'light'); + expect(svg).toContain('fill="#ffffff"'); + expect(svg).not.toContain('radialGradient'); + }); + + it('a custom canvas paints the caller-provided solid color, no gradient defs', () => { + // ExportMenu passes the custom color as backgroundColor + readable text as defaultTextColor. + const svg = buildStandaloneSvgString({ + width: 200, + height: 120, + backgroundColor: '#0055ff', + defaultTextColor: '#ffffff', + style: 'bauhaus', + backgroundId: 'custom', + colorTokensByLink: false, + lineStyle: 'curved', + lineThickness: 3, + lineOpacity: 1, + lineOrder: ['s', 't'], + lines: [ + { + lineId: 's', + tokens: [tok('s-0', 'Hi')], + fontFamilyStack: 'Inter, sans-serif', + textSizePx: 36 + }, + { + lineId: 't', + tokens: [tok('t-0', 'Salut')], + fontFamilyStack: 'Inter, sans-serif', + textSizePx: 36 + } + ], + tokenLayout: { 's-0': box(20, 20), 't-0': box(20, 80) }, + connections, + pairControls: [], + includeAttributionFooter: false + }); + expect(svg).toContain('fill="#0055ff"'); + expect(svg).not.toContain('Gradient'); + }); +}); diff --git a/bitext/src/lib/export/svg.ts b/bitext/src/lib/export/svg.ts index 599eeb5..c4474c3 100644 --- a/bitext/src/lib/export/svg.ts +++ b/bitext/src/lib/export/svg.ts @@ -9,11 +9,14 @@ import { linkEndpoints, linkPathD, ribbonPathD } from '$lib/domain/link-geometry import { connectorColor, getStyle, + getBackground, + isPlainBackground, readableTextOn, shiftHue, - styleExportBackground, + backgroundExport, styleExportFrame, - type StyleId + type StyleId, + type BackgroundId } from '$lib/domain/styles.js'; import { chromeScale, @@ -146,8 +149,10 @@ export function buildStandaloneSvgString(args: { /** Matches on-screen preview / raster exports (PNG, PDF). Used for the `classic` style. */ backgroundColor: string; defaultTextColor: string; - /** Visual style preset; drives canvas, frame and connector treatment. */ + /** Visual style preset; drives frame and connector treatment. */ style?: StyleId; + /** Independent canvas override. Unset → the style's own canvas (or classic light/dark). */ + backgroundId?: BackgroundId; colorTokensByLink: boolean; tokenLinkColorMode?: TokenLinkColorMode; lineStyle: 'straight' | 'curved'; @@ -182,6 +187,7 @@ export function buildStandaloneSvgString(args: { backgroundColor, defaultTextColor, style = 'classic', + backgroundId, colorTokensByLink, tokenLinkColorMode = 'text', lineStyle, @@ -202,9 +208,15 @@ export function buildStandaloneSvgString(args: { const visualStyle = getStyle(style); const conn = visualStyle.connector; - const isClassic = visualStyle.id === 'classic'; - const resolvedTextColor = isClassic ? defaultTextColor : visualStyle.canvas.textColor; - const tintBase = isClassic ? backgroundColor : visualStyle.canvas.tintBaseHex; + // Effective canvas: an explicit override, else the style's own (classic → the plain fill). + const bgId: BackgroundId = + backgroundId ?? (style === 'classic' ? 'light' : (style as BackgroundId)); + // Plain light/dark and the custom solid color are painted from the caller-provided fill/text; + // themed canvases bring their own. + const callerFill = isPlainBackground(bgId) || bgId === 'custom'; + const bgCanvas = callerFill ? null : getBackground(bgId).canvas; + const resolvedTextColor = bgCanvas ? bgCanvas.textColor : defaultTextColor; + const tintBase = bgCanvas ? bgCanvas.tintBaseHex : backgroundColor; // Fixed-canvas (social preset) geometry: card size, padding and inner area. const cardW = frame ? Math.max(1, frame.width) : 0; @@ -283,7 +295,7 @@ export function buildStandaloneSvgString(args: { const bgY = frame ? 0 : cropY; const bgW = frame ? cardW : cropW; const bgH = frame ? cardH : cropH; - const exportBg = styleExportBackground(visualStyle, bgX, bgY, bgW, bgH); + const exportBg = backgroundExport(bgId, bgX, bgY, bgW, bgH); const frameSvg = styleExportFrame(visualStyle, bgX, bgY, bgW, bgH); const styleChunks: string[] = []; diff --git a/bitext/src/lib/serialization/compact-v4.ts b/bitext/src/lib/serialization/compact-v4.ts index 3db73ff..9e80972 100644 --- a/bitext/src/lib/serialization/compact-v4.ts +++ b/bitext/src/lib/serialization/compact-v4.ts @@ -65,6 +65,10 @@ function settingsToCompact(rounded: VisualSettingsV2): CompactSettings4 | undefi } if (rounded.tokenPunctuationChars) o.px = rounded.tokenPunctuationChars; if (rounded.style !== def.style) o.st = rounded.style; + if (rounded.backgroundId !== undefined) o.bi = rounded.backgroundId; + if (rounded.backgroundId === 'custom' && rounded.backgroundCustomColor) { + o.bc = rounded.backgroundCustomColor.replace(/^#/u, ''); + } if (rounded.autoFit !== def.autoFit) o.af = rounded.autoFit ? 1 : 0; if (rounded.autoFitVariance !== def.autoFitVariance) o.av = rounded.autoFitVariance; if (rounded.background !== def.background) { @@ -97,6 +101,8 @@ function compactToVisualSettings(s: CompactSettings4 | undefined): VisualSetting raw.background = n === 1 ? 'dark' : 'light'; } if (s.st !== undefined) raw.style = String(s.st); + if (s.bi !== undefined) raw.backgroundId = String(s.bi); + if (s.bc !== undefined) raw.backgroundCustomColor = `#${String(s.bc)}`; if (s.af !== undefined) raw.autoFit = Number(s.af) === 1; if (s.av !== undefined) raw.autoFitVariance = Number(s.av); return normalizeVisualSettingsV2(raw); diff --git a/bitext/src/lib/serialization/schema.ts b/bitext/src/lib/serialization/schema.ts index 6f264ce..ee478b9 100644 --- a/bitext/src/lib/serialization/schema.ts +++ b/bitext/src/lib/serialization/schema.ts @@ -5,7 +5,7 @@ import { } from '$lib/domain/alignment.js'; import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js'; import { PALETTES, isPaletteName, type PaletteName } from '$lib/domain/palettes.js'; -import { isStyleId, type StyleId } from '$lib/domain/styles.js'; +import { isStyleId, isBackgroundId, type StyleId, type BackgroundId } from '$lib/domain/styles.js'; export const SCHEMA_VERSION = 2 as const; /** @deprecated Legacy share payloads only */ @@ -165,6 +165,13 @@ export interface VisualSettingsV2 { background: BackgroundMode; /** Visual style preset (background, frame, connector treatment, default font). */ style: StyleId; + /** + * Independent canvas override. Unset → the style's own canvas (Classic follows `background`); + * set → that background is drawn under any style. Additive: absent in old payloads. + */ + backgroundId?: BackgroundId; + /** Remembered color for the `custom` background (used only when `backgroundId === 'custom'`). */ + backgroundCustomColor?: string; /** Shrink text to fit so a line never wraps to a second display row. */ autoFit: boolean; /** 0 = all lines share one scale (uniform); 1 = each line fits independently. */ @@ -794,6 +801,12 @@ export function normalizeVisualSettingsV2( typeof raw.previewHideChrome === 'boolean' ? raw.previewHideChrome : d.previewHideChrome, background: normalizePreviewBackground(raw.background ?? d.background), style: isStyleId(raw.style) ? raw.style : d.style, + backgroundId: isBackgroundId(raw.backgroundId) ? raw.backgroundId : undefined, + backgroundCustomColor: + typeof raw.backgroundCustomColor === 'string' && + /^#[0-9a-fA-F]{6}$/u.test(raw.backgroundCustomColor) + ? raw.backgroundCustomColor + : undefined, autoFit: typeof raw.autoFit === 'boolean' ? raw.autoFit : d.autoFit, autoFitVariance: typeof raw.autoFitVariance === 'number' && Number.isFinite(raw.autoFitVariance) diff --git a/bitext/src/lib/serialization/serialization-roundtrip.test.ts b/bitext/src/lib/serialization/serialization-roundtrip.test.ts index 5218bec..b6e4543 100644 --- a/bitext/src/lib/serialization/serialization-roundtrip.test.ts +++ b/bitext/src/lib/serialization/serialization-roundtrip.test.ts @@ -95,6 +95,35 @@ describe('compact v4 encode/decode (current share format)', () => { expect(decodeState(encodeState(classic)).settings.style).toBe('classic'); }); + it('round-trip: independent background override survives a share link', () => { + const base = migrate({}); + const s: AppStateV2 = { + ...base, + settings: { ...base.settings, style: 'bauhaus', backgroundId: 'aurora' } + }; + expect(decodeState(encodeState(s)).settings.backgroundId).toBe('aurora'); + }); + + it('no override stays out of the URL and decodes to undefined', () => { + const base = migrate({}); + expect(base.settings.backgroundId).toBeUndefined(); + expect(encodeState(base)).toBe( + encodeState({ ...base, settings: { ...base.settings, backgroundId: undefined } }) + ); + expect(decodeState(encodeState(base)).settings.backgroundId).toBeUndefined(); + }); + + it('round-trip: custom canvas carries its color', () => { + const base = migrate({}); + const s: AppStateV2 = { + ...base, + settings: { ...base.settings, backgroundId: 'custom', backgroundCustomColor: '#0055ff' } + }; + const decoded = decodeState(encodeState(s)).settings; + expect(decoded.backgroundId).toBe('custom'); + expect(decoded.backgroundCustomColor).toBe('#0055ff'); + }); + it('round-trip: auto-fit off + variance; defaults stay out of the URL', () => { const base = migrate({}); expect(base.settings.autoFit).toBe(true);