Compare commits

11 Commits

Author SHA1 Message Date
ilia c78b8e032e Merge pull request 'Feature/adaptive crossfade window' (#50) from feature/adaptive-crossfade-window into main
Workflow / build (push) Successful in 1m15s
Workflow / e2e (push) Successful in 1m4s
Workflow / publish (push) Successful in 14s
Reviewed-on: #50
2026-06-06 06:05:08 +00:00
Ilia Mashkov 11d5ba0e63 refactor(ComparisonView): extract strut-height and settled-text from Line
Workflow / build (pull_request) Successful in 1m27s
Workflow / e2e (pull_request) Successful in 1m15s
Workflow / publish (pull_request) Has been skipped
Pull the baseline-strut height math into a documented computeStrutHeight
util (named constants for the empirical 0.34 / 1.1 factors, with a unit
test) and the per-side native text runs into a SettledText component.
Strut statics move to Tailwind classes with only the height as style:height;
drop the now-redundant style-string $derived bindings.
2026-06-06 08:59:21 +03:00
Ilia Mashkov 99e9a1fb2c docs(Font): record short-line crossfade pop tradeoff on WINDOW_MIN 2026-06-03 16:13:16 +03:00
Ilia Mashkov 5084df3914 test(e2e): document single-line ASCII constraint on preview sample 2026-06-03 16:10:31 +03:00
Ilia Mashkov a2ec025a65 test(e2e): assert crossfade window against the sizing rule 2026-06-03 16:07:48 +03:00
Ilia Mashkov 8dbea97a33 docs(ComparisonView): note per-line window sizing in Line header 2026-06-03 16:06:35 +03:00
Ilia Mashkov 744cdc9d19 refactor(ComparisonView): size crossfade window per line 2026-06-03 16:03:51 +03:00
Ilia Mashkov 600b905e01 feat(Font): add windowSizeForLine crossfade-window policy 2026-06-03 16:00:29 +03:00
ilia 4ad0fe4cfa Merge pull request 'Refactor/reacrhitecture to fsd+' (#49) from refactor/reacrhitecture-to-fsd+ into main
Workflow / build (push) Successful in 1m6s
Workflow / e2e (push) Successful in 58s
Workflow / publish (push) Successful in 24s
Reviewed-on: #49
2026-06-03 09:55:46 +00:00
Ilia Mashkov eafe89b313 test: change old test to work with new grapheme split mechanism
Workflow / build (pull_request) Successful in 1m16s
Workflow / e2e (pull_request) Successful in 1m11s
Workflow / publish (pull_request) Has been skipped
2026-06-03 12:50:03 +03:00
Ilia Mashkov 724b00d3d5 test(layoutStore): silence expected warn in invalid-JSON case
The "defaults to list mode when localStorage has invalid data" test feeds
invalid JSON on purpose; createPersistentStore logs and swallows the parse
error, so its warning (with stack) was polluting CI output. Spy on
console.warn to silence it and assert it fired, matching the equivalent
test in createPersistentStore.test.ts.
2026-06-03 11:45:13 +03:00
12 changed files with 230 additions and 50 deletions
+15 -4
View File
@@ -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);
// Each grapheme renders as a `.char-wrap` cell in the slider once
// both fonts are loaded. Six glyphs → six cells.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
// 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 }) => {
+1
View File
@@ -8,3 +8,4 @@ export {
findSplitIndex,
type LineRenderModel,
} from './computeLineRenderModel/computeLineRenderModel';
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
@@ -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);
});
});
@@ -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 12, 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)));
}
+1
View File
@@ -2,6 +2,7 @@ export {
computeLineRenderModel,
DualFontLayout,
findSplitIndex,
windowSizeForLine,
} from './domain';
export type {
ComparisonLine,
+1
View File
@@ -1,3 +1,4 @@
export { computeStrutHeight } from './utils/computeStrutHeight/computeStrutHeight';
export {
createDotCrossfade,
getDotTransitionParams,
@@ -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);
});
});
@@ -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);
}
+18 -39
View File
@@ -1,7 +1,8 @@
<!--
Component: Line
Renders one laid-out line as three regions: a fontA bulk run (past the
slider), an N-char crossfade window straddling it, and a fontB bulk run (not
slider), a crossfade window straddling it (its size derived per line from
the line's grapheme count via `windowSizeForLine`), and a fontB bulk run (not
yet past). Bulk runs are native shaped text (kerning, ligatures); only the
window uses per-char DOM. `split` is a primitive so the render-model
`$derived` skips recomputation on ticks that leave it unchanged.
@@ -10,10 +11,13 @@
import {
type ComparisonLine,
computeLineRenderModel,
windowSizeForLine,
} from '$entities/Font';
import { getTypographySettingsStore } from '$features/AdjustTypography';
import { computeStrutHeight } from '../../lib';
import { getComparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
import SettledText from '../SettledText/SettledText.svelte';
interface Props {
/**
@@ -24,16 +28,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();
@@ -44,36 +46,9 @@ const fontSizePx = $derived(typography.renderedSize);
const lineHeightPx = $derived(typography.height * typography.renderedSize);
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
/**
* Class and style are single short bindings so the formatter keeps
* `<span ...>{text}</span>` on one line. A wrapped text expression would leak
* its indentation into the span content under `white-space: pre`.
*/
const BULK_LEFT_CLASS =
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300';
const BULK_RIGHT_CLASS =
'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300';
const leftStyle = $derived(`font-family:${fontA?.name ?? ''};font-size:${fontSizePx}px`);
const rightStyle = $derived(`font-family:${fontB?.name ?? ''};font-size:${fontSizePx}px`);
/**
* Stops the whole line from jumping up or down as the slider moves. The browser
* pins a line box's baseline to its tallest inline box, so without a fixed
* reference the baseline (and every glyph) shifts the moment a bulk run appears
* or disappears, or the last window char morphs to a font with a taller ascent.
* This invisible strut is always the tallest box — `overflow: hidden` puts its
* baseline at its bottom edge — so it owns the line baseline and holds it still.
* Its height also sets the text's vertical position (the container is block, so
* nothing else centers it).
*
* Height factors are empirical: the first term centers the text, the `* 1.1`
* floor keeps the strut above the fonts' ascent at tight line-heights.
*/
const strutHeightPx = $derived(Math.max(lineHeightPx / 2 + fontSizePx * 0.34, fontSizePx * 1.1));
const strutStyle = $derived(
`display:inline-block;width:0;overflow:hidden;vertical-align:baseline;height:${strutHeightPx}px`,
);
// Invisible strut that pins the line baseline so glyphs don't jump as the
// slider moves; `computeStrutHeight` explains the why and the formula.
const strutHeightPx = $derived(computeStrutHeight(lineHeightPx, fontSizePx));
</script>
<!--
@@ -91,15 +66,19 @@ const strutStyle = $derived(
style:letter-spacing="{letterSpacingPx}px"
style:font-weight={typography.weight}
>
<span style={strutStyle} aria-hidden="true"></span>
<span
class="inline-block w-0 overflow-hidden align-baseline"
style:height="{strutHeightPx}px"
aria-hidden="true"
></span>
{#if model.leftText}
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
<SettledText text={model.leftText} fontFamily={fontA.name} fontSize={fontSizePx} side="left" />
{/if}
{#each model.windowChars as wc (wc.key)}
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
{/each}
{#if model.rightText}
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
<SettledText text={model.rightText} fontFamily={fontB.name} fontSize={fontSizePx} side="right" />
{/if}
</div>
{/if}
@@ -0,0 +1,37 @@
<!--
Component: SettledText
One side's text settled in a single font — left is fontA the slider has
passed, right is fontB not yet reached. A native shaped run (kerning,
ligatures); the crossfading middle uses per-char Character cells instead.
-->
<script lang="ts">
interface Props {
/**
* Run text.
*/
text: string;
/**
* CSS font-family name.
*/
fontFamily: string;
/**
* Font size in px.
*/
fontSize: number;
/**
* Window side — selects the color treatment.
*/
side: 'left' | 'right';
}
let { text, fontFamily, fontSize, side }: Props = $props();
// Left (fontA, passed) is dimmed; right (fontB, pending) is full-strength.
const SIDE_CLASS: Record<Props['side'], string> = {
left:
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300',
right: 'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300',
};
</script>
<span class={SIDE_CLASS[side]} style:font-family="'{fontFamily}'" style:font-size="{fontSize}px">{text}</span>
@@ -107,12 +107,6 @@ const layout = new DualFontLayout();
let layoutResult = $state<ComparisonResult>({ 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)}
<Line {line} {split} windowSize={WINDOW_SIZE} />
<Line {line} {split} />
{/each}
</div>
@@ -7,6 +7,7 @@ import {
describe,
expect,
it,
vi,
} from 'vitest';
// Helper to flush Svelte effects (they run in microtasks)
@@ -69,12 +70,17 @@ describe('layoutStore', () => {
});
it('should default to list mode when localStorage has invalid data', async () => {
// createPersistentStore logs and swallows the parse error; silence
// the expected warn so it doesn't pollute test output, and assert it.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
localStorage.setItem(STORAGE_KEY, 'invalid json');
const { LayoutManager } = await import('./layoutStore.svelte');
const manager = new LayoutManager();
expect(manager.mode).toBe('list');
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('should default to list mode when localStorage has empty object', async () => {