diff --git a/e2e/preview-text.test.ts b/e2e/preview-text.test.ts
index c3e60f5..c2565b5 100644
--- a/e2e/preview-text.test.ts
+++ b/e2e/preview-text.test.ts
@@ -1,3 +1,4 @@
+import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
import {
expect,
test,
@@ -5,12 +6,22 @@ import {
test.describe('preview text', () => {
test('drives the slider character rendering', async ({ comparison }) => {
+ /**
+ * Must stay a single unwrapped line of ASCII: the assertion feeds
+ * `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
+ * renderer feeds it the line's grapheme count. They match only for
+ * plain ASCII — emoji/combining marks (length > graphemes) or wrapping
+ * (one input string splitting into several lines) silently desync them.
+ */
+ const text = 'Sphinx';
await comparison.pickPair('Inter', 'Roboto');
- await comparison.setPreviewText('Sphinx');
+ await comparison.setPreviewText(text);
- // Window chars render as `.char-wrap` cells for crossfade.
- // With WINDOW_SIZE=5, "Sphinx" (6 chars) fits 5 in the window.
- await expect(comparison.slider.locator('.char-wrap')).toHaveCount(5);
+ // Window chars render as `.char-wrap` cells for crossfade. The window
+ // size is a pure function of the line's grapheme count — assert against
+ // the rule, not a hardcoded constant, so tuning the policy can't silently
+ // break this. "Sphinx" is one unwrapped line of 6 graphemes.
+ await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
});
test('preserves the typed value in the input', async ({ comparison }) => {
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..d790360
--- /dev/null
+++ b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ *
+ * Accepted tradeoff: short lines now get a band of 1–2, so a fast slider drag
+ * can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
+ * Worth it for the "bulk committed, small band shimmering" look on short lines;
+ * raising this trades that pop back for less committed bulk.
+ */
+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,
diff --git a/src/widgets/ComparisonView/lib/index.ts b/src/widgets/ComparisonView/lib/index.ts
index f84d781..942760e 100644
--- a/src/widgets/ComparisonView/lib/index.ts
+++ b/src/widgets/ComparisonView/lib/index.ts
@@ -1,3 +1,4 @@
+export { computeStrutHeight } from './utils/computeStrutHeight/computeStrutHeight';
export {
createDotCrossfade,
getDotTransitionParams,
diff --git a/src/widgets/ComparisonView/lib/utils/computeStrutHeight/computeStrutHeight.test.ts b/src/widgets/ComparisonView/lib/utils/computeStrutHeight/computeStrutHeight.test.ts
new file mode 100644
index 0000000..b6a2dfa
--- /dev/null
+++ b/src/widgets/ComparisonView/lib/utils/computeStrutHeight/computeStrutHeight.test.ts
@@ -0,0 +1,28 @@
+import {
+ describe,
+ expect,
+ it,
+} from 'vitest';
+import { computeStrutHeight } from './computeStrutHeight';
+
+describe('computeStrutHeight', () => {
+ it('uses the centering height when the line-height is generous', () => {
+ // centering = 40/2 + 16*0.34 = 25.44; floor = 16*1.1 = 17.6 → centering wins.
+ expect(computeStrutHeight(40, 16)).toBeCloseTo(25.44, 5);
+ });
+
+ it('falls back to the ascent floor when the line-height is tight', () => {
+ // centering = 16/2 + 16*0.34 = 13.44; floor = 16*1.1 = 17.6 → floor wins.
+ expect(computeStrutHeight(16, 16)).toBeCloseTo(17.6, 5);
+ });
+
+ it('treats the floor and centering height as equal at the crossover line-height', () => {
+ // centering == floor when lineHeight = 1.52 * fontSize → 24.32 for 16px.
+ expect(computeStrutHeight(24.32, 16)).toBeCloseTo(17.6, 5);
+ });
+
+ it('scales with font size', () => {
+ // centering = 60/2 + 32*0.34 = 40.88; floor = 32*1.1 = 35.2 → centering wins.
+ expect(computeStrutHeight(60, 32)).toBeCloseTo(40.88, 5);
+ });
+});
diff --git a/src/widgets/ComparisonView/lib/utils/computeStrutHeight/computeStrutHeight.ts b/src/widgets/ComparisonView/lib/utils/computeStrutHeight/computeStrutHeight.ts
new file mode 100644
index 0000000..cafe65b
--- /dev/null
+++ b/src/widgets/ComparisonView/lib/utils/computeStrutHeight/computeStrutHeight.ts
@@ -0,0 +1,45 @@
+/**
+ * Fraction of the font size added to half the line height to drop the strut's
+ * baseline from the line box's vertical middle down to the text's optical
+ * center. Empirical: ~0.34em approximates a Latin font's midline-to-baseline
+ * offset, so glyphs sit centered rather than riding high in the line.
+ */
+const BASELINE_OFFSET_RATIO = 0.34;
+
+/**
+ * Minimum strut height as a multiple of the font size. Floors the strut above
+ * the fonts' ascent (~1em) so that at tight line-heights it stays the tallest
+ * inline box and keeps ownership of the line baseline. Empirical: 1.1 clears the
+ * tallest ascenders in the catalog's Latin fonts.
+ */
+const MIN_HEIGHT_RATIO = 1.1;
+
+/**
+ * Pixel height for a slider line's invisible baseline strut.
+ *
+ * The slider renders each line with a zero-width strut span whose box is
+ * deliberately the tallest inline box on the line. The browser pins a line box's
+ * baseline to its tallest inline box; fixing the strut's height independent of
+ * which bulk runs or window chars are currently mounted keeps the baseline (and
+ * every glyph) from jumping as the slider sweeps runs in and out. With
+ * `overflow: hidden` the strut's baseline sits at its bottom edge, so this height
+ * also sets the text's vertical position within the line box.
+ *
+ * The result is `max(centeringHeight, ascentFloor)`:
+ * - `centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO`
+ * centers the text — half the line box places the strut's bottom edge at the
+ * vertical middle, and the offset term nudges the baseline down to the glyphs'
+ * optical center.
+ * - `ascentFloor = fontSizePx * MIN_HEIGHT_RATIO` keeps the strut taller than the
+ * fonts' ascent when the line-height is tight (where `centeringHeight` would
+ * shrink below a real glyph box and let another box steal the baseline).
+ *
+ * @param lineHeightPx Line height in pixels (typography line-height × font size).
+ * @param fontSizePx Rendered font size in pixels.
+ * @returns Strut height in pixels.
+ */
+export function computeStrutHeight(lineHeightPx: number, fontSizePx: number): number {
+ const centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO;
+ const ascentFloor = fontSizePx * MIN_HEIGHT_RATIO;
+ return Math.max(centeringHeight, ascentFloor);
+}
diff --git a/src/widgets/ComparisonView/ui/Line/Line.svelte b/src/widgets/ComparisonView/ui/Line/Line.svelte
index 96e8b6d..df864c5 100644
--- a/src/widgets/ComparisonView/ui/Line/Line.svelte
+++ b/src/widgets/ComparisonView/ui/Line/Line.svelte
@@ -1,7 +1,8 @@
+
+
+{text}
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}