Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions bitext/src/lib/components/preview/AlignmentPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,10 +51,19 @@

let rootEl = $state<HTMLElement | null>(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`;
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion bitext/src/lib/components/preview/StylePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
24 changes: 17 additions & 7 deletions bitext/src/lib/components/preview/TokenView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
35 changes: 28 additions & 7 deletions bitext/src/lib/components/share/ExportMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,16 +79,29 @@
await new Promise<void>((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`. */
Expand Down Expand Up @@ -126,6 +146,7 @@
backgroundColor: exportBackgroundColor(),
defaultTextColor: exportTextColor(),
style: s.style,
backgroundId: effectiveBackgroundId(),
colorTokensByLink: s.colorTokensByLink,
tokenLinkColorMode: s.tokenLinkColorMode,
lineStyle: s.lineStyle,
Expand Down
91 changes: 75 additions & 16 deletions bitext/src/lib/components/shell/panels/StylePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -24,21 +36,52 @@
</script>

<div class="flex flex-col gap-4">
<!-- Canvas: only Classic exposes a light/dark switch; other themes bring their own background. -->
{#if isClassic}
<section aria-labelledby="style-section-canvas" class={section}>
<h3 id="style-section-canvas" class="{sectionTitle} mb-2">Canvas</h3>
<SegmentedControl
label="Canvas background"
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]}
value={s.background}
onSelect={(v) => settingsStore.patch({ background: v as 'light' | 'dark' })}
/>
</section>
{/if}
<!-- Canvas: the style sets a default background; any style can be moved onto another one. -->
<section aria-labelledby="style-section-canvas" class={section}>
<h3 id="style-section-canvas" class="{sectionTitle} mb-2">Canvas</h3>
<div class="grid grid-cols-2 gap-2" role="radiogroup" aria-label="Canvas background">
{#each BACKGROUNDS_LIST as b (b.id)}
{@const selected = currentBackgroundId === b.id}
<button
type="button"
role="radio"
aria-checked={selected}
class="flex items-center gap-2 border p-1.5 text-left transition-colors {selected
? 'border-primary-500 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
onclick={() => settingsStore.patch({ backgroundId: b.id })}
>
<span
class="h-6 w-6 shrink-0 rounded-sm border border-black/15"
style:background={b.canvas.previewBackground}
></span>
<span class="truncate text-xs font-medium text-gray-800 dark:text-gray-100"
>{b.label}</span
>
</button>
{/each}
<label
class="flex cursor-pointer items-center gap-2 border p-1.5 text-left transition-colors {currentBackgroundId ===
'custom'
? 'border-primary-500 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
>
<input
type="color"
class="canvas-color-swatch h-6 w-6 shrink-0 cursor-pointer overflow-hidden rounded-sm border border-black/15 bg-transparent p-0"
aria-label="Custom canvas color"
value={customColor}
onclick={() => chooseCustomColor(customColor)}
oninput={(e) => chooseCustomColor((e.currentTarget as HTMLInputElement).value)}
/>
<span class="truncate text-xs font-medium text-gray-800 dark:text-gray-100">Custom</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Picking a style resets this. Choose a background to keep a style's words and links on another
canvas.
</p>
</section>

<!-- Colors: link palette + how it spills onto the words -->
<section aria-labelledby="style-section-colors" class={section}>
Expand Down Expand Up @@ -207,3 +250,19 @@
<FontsTab />
</section>
</div>

<style>
/* Let the native color input read as a flat swatch like its sibling tiles: drop the
built-in wrapper padding and the inner swatch border so the color fills the square. */
:global(.canvas-color-swatch::-webkit-color-swatch-wrapper) {
padding: 0;
}
:global(.canvas-color-swatch::-webkit-color-swatch) {
border: none;
border-radius: 0.125rem;
}
:global(.canvas-color-swatch::-moz-color-swatch) {
border: none;
border-radius: 0.125rem;
}
</style>
102 changes: 101 additions & 1 deletion bitext/src/lib/domain/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading