From 600b905e016bc26f54b946261bdda7855af7f81d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 3 Jun 2026 16:00:29 +0300 Subject: [PATCH 1/7] feat(Font): add windowSizeForLine crossfade-window policy --- src/entities/Font/domain/index.ts | 1 + .../windowSizeForLine.test.ts | 38 +++++++++++++++++++ .../windowSizeForLine/windowSizeForLine.ts | 34 +++++++++++++++++ src/entities/Font/index.ts | 1 + 4 files changed, 74 insertions(+) create mode 100644 src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts create mode 100644 src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts diff --git a/src/entities/Font/domain/index.ts b/src/entities/Font/domain/index.ts index bffc09f..0f8a2ea 100644 --- a/src/entities/Font/domain/index.ts +++ b/src/entities/Font/domain/index.ts @@ -8,3 +8,4 @@ export { findSplitIndex, type LineRenderModel, } from './computeLineRenderModel/computeLineRenderModel'; +export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine'; diff --git a/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts new file mode 100644 index 0000000..392871b --- /dev/null +++ b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts @@ -0,0 +1,38 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { windowSizeForLine } from './windowSizeForLine'; + +describe('windowSizeForLine', () => { + it('returns 0 for an empty or non-positive line', () => { + expect(windowSizeForLine(0)).toBe(0); + expect(windowSizeForLine(-3)).toBe(0); + }); + + it('floors non-empty short lines at the minimum window of 1', () => { + expect(windowSizeForLine(1)).toBe(1); + expect(windowSizeForLine(2)).toBe(1); + expect(windowSizeForLine(3)).toBe(1); + }); + + it('scales with round(n / 3) in the mid range', () => { + expect(windowSizeForLine(6)).toBe(2); + expect(windowSizeForLine(12)).toBe(4); + }); + + it('caps at the maximum window of 5', () => { + expect(windowSizeForLine(15)).toBe(5); + expect(windowSizeForLine(16)).toBe(5); + expect(windowSizeForLine(100)).toBe(5); + }); + + it('rounds to nearest at fractional boundaries', () => { + // round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5 + expect(windowSizeForLine(4)).toBe(1); + expect(windowSizeForLine(5)).toBe(2); + expect(windowSizeForLine(13)).toBe(4); + expect(windowSizeForLine(14)).toBe(5); + }); +}); diff --git a/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts new file mode 100644 index 0000000..daa9f6d --- /dev/null +++ b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts @@ -0,0 +1,34 @@ +/** + * Crossfade-window sizing policy for the dual-font slider. + * + * The slider renders a band of per-char `Character` cells that opacity-crossfade + * between the two fonts; everything outside the band is committed native bulk + * text. A fixed band looked wrong on short lines — a 6-grapheme line left almost + * no bulk, so nearly the whole line shimmered as per-char DOM. The band size + * therefore scales with the line's grapheme count and caps so long lines don't + * pay for an oversized per-char DOM band. + */ + +/** + * Fraction of a line's graphemes that sit in the crossfade band. + */ +const WINDOW_RATIO = 1 / 3; +/** + * Smallest band for a non-empty line — guarantees at least one crossfading char. + */ +const WINDOW_MIN = 1; +/** + * Largest band regardless of line length — bounds per-char DOM cost. + */ +const WINDOW_MAX = 5; + +/** + * Crossfade window size, in graphemes, for a line of `n` graphemes. + * `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window. + */ +export function windowSizeForLine(n: number): number { + if (n <= 0) { + return 0; + } + return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO))); +} diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index b93f0fc..a59a859 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -2,6 +2,7 @@ export { computeLineRenderModel, DualFontLayout, findSplitIndex, + windowSizeForLine, } from './domain'; export type { ComparisonLine, -- 2.52.0 From 744cdc9d19f90cae7d09d2ce9118927a0c57e92d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 3 Jun 2026 16:03:51 +0300 Subject: [PATCH 2/7] refactor(ComparisonView): size crossfade window per line --- src/widgets/ComparisonView/ui/Line/Line.svelte | 9 ++++----- .../ComparisonView/ui/SliderArea/SliderArea.svelte | 8 +------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/widgets/ComparisonView/ui/Line/Line.svelte b/src/widgets/ComparisonView/ui/Line/Line.svelte index 96e8b6d..4721a1e 100644 --- a/src/widgets/ComparisonView/ui/Line/Line.svelte +++ b/src/widgets/ComparisonView/ui/Line/Line.svelte @@ -10,6 +10,7 @@ import { type ComparisonLine, computeLineRenderModel, + windowSizeForLine, } from '$entities/Font'; import { getTypographySettingsStore } from '$features/AdjustTypography'; import { getComparisonStore } from '../../model'; @@ -24,16 +25,14 @@ interface Props { * Count of chars the slider has passed, from `findSplitIndex`. */ split: number; - /** - * Number of chars in the crossfade window around the split. - */ - windowSize: number; } -let { line, split, windowSize }: Props = $props(); +let { line, split }: Props = $props(); const comparisonStore = getComparisonStore(); +const windowSize = $derived(windowSizeForLine(line.chars.length)); + const model = $derived(computeLineRenderModel(line, split, windowSize)); const typography = getTypographySettingsStore(); diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index 8b98abe..e98f9dd 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -107,12 +107,6 @@ const layout = new DualFontLayout(); let layoutResult = $state({ lines: [], totalHeight: 0 }); -/** - * N-window size for the per-char crossfade zone around the slider split. - * Tuned so chars complete their 100ms opacity crossfade before exiting the window. - */ -const WINDOW_SIZE = 5; - // Track container width changes (window resize, sidebar toggle, etc.) $effect(() => { if (!container) { @@ -344,7 +338,7 @@ $effect(() => { > {#each layoutResult.lines as line, lineIdx (lineIdx)} {@const split = findSplitIndex(line, sliderPos, containerWidth)} - + {/each} -- 2.52.0 From 8dbea97a3355f3df2516d3fe1322f65a2fef4f7a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 3 Jun 2026 16:06:35 +0300 Subject: [PATCH 3/7] docs(ComparisonView): note per-line window sizing in Line header --- src/widgets/ComparisonView/ui/Line/Line.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/widgets/ComparisonView/ui/Line/Line.svelte b/src/widgets/ComparisonView/ui/Line/Line.svelte index 4721a1e..92350b2 100644 --- a/src/widgets/ComparisonView/ui/Line/Line.svelte +++ b/src/widgets/ComparisonView/ui/Line/Line.svelte @@ -1,7 +1,8 @@ + + +{text} -- 2.52.0