Feature/adaptive crossfade window #50
@@ -1,3 +1,4 @@
|
|||||||
|
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
test,
|
test,
|
||||||
@@ -5,12 +6,22 @@ import {
|
|||||||
|
|
||||||
test.describe('preview text', () => {
|
test.describe('preview text', () => {
|
||||||
test('drives the slider character rendering', async ({ comparison }) => {
|
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.pickPair('Inter', 'Roboto');
|
||||||
await comparison.setPreviewText('Sphinx');
|
await comparison.setPreviewText(text);
|
||||||
|
|
||||||
// Window chars render as `.char-wrap` cells for crossfade.
|
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||||
// With WINDOW_SIZE=5, "Sphinx" (6 chars) fits 5 in the window.
|
// size is a pure function of the line's grapheme count — assert against
|
||||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(5);
|
// 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 }) => {
|
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export {
|
|||||||
findSplitIndex,
|
findSplitIndex,
|
||||||
type LineRenderModel,
|
type LineRenderModel,
|
||||||
} from './computeLineRenderModel/computeLineRenderModel';
|
} 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 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)));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
computeLineRenderModel,
|
computeLineRenderModel,
|
||||||
DualFontLayout,
|
DualFontLayout,
|
||||||
findSplitIndex,
|
findSplitIndex,
|
||||||
|
windowSizeForLine,
|
||||||
} from './domain';
|
} from './domain';
|
||||||
export type {
|
export type {
|
||||||
ComparisonLine,
|
ComparisonLine,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { computeStrutHeight } from './utils/computeStrutHeight/computeStrutHeight';
|
||||||
export {
|
export {
|
||||||
createDotCrossfade,
|
createDotCrossfade,
|
||||||
getDotTransitionParams,
|
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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: Line
|
Component: Line
|
||||||
Renders one laid-out line as three regions: a fontA bulk run (past the
|
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
|
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
|
window uses per-char DOM. `split` is a primitive so the render-model
|
||||||
`$derived` skips recomputation on ticks that leave it unchanged.
|
`$derived` skips recomputation on ticks that leave it unchanged.
|
||||||
@@ -10,10 +11,13 @@
|
|||||||
import {
|
import {
|
||||||
type ComparisonLine,
|
type ComparisonLine,
|
||||||
computeLineRenderModel,
|
computeLineRenderModel,
|
||||||
|
windowSizeForLine,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { getTypographySettingsStore } from '$features/AdjustTypography';
|
import { getTypographySettingsStore } from '$features/AdjustTypography';
|
||||||
|
import { computeStrutHeight } from '../../lib';
|
||||||
import { getComparisonStore } from '../../model';
|
import { getComparisonStore } from '../../model';
|
||||||
import Character from '../Character/Character.svelte';
|
import Character from '../Character/Character.svelte';
|
||||||
|
import SettledText from '../SettledText/SettledText.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -24,16 +28,14 @@ interface Props {
|
|||||||
* Count of chars the slider has passed, from `findSplitIndex`.
|
* Count of chars the slider has passed, from `findSplitIndex`.
|
||||||
*/
|
*/
|
||||||
split: number;
|
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 comparisonStore = getComparisonStore();
|
||||||
|
|
||||||
|
const windowSize = $derived(windowSizeForLine(line.chars.length));
|
||||||
|
|
||||||
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
||||||
|
|
||||||
const typography = getTypographySettingsStore();
|
const typography = getTypographySettingsStore();
|
||||||
@@ -44,36 +46,9 @@ const fontSizePx = $derived(typography.renderedSize);
|
|||||||
const lineHeightPx = $derived(typography.height * typography.renderedSize);
|
const lineHeightPx = $derived(typography.height * typography.renderedSize);
|
||||||
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
|
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
|
||||||
|
|
||||||
/**
|
// Invisible strut that pins the line baseline so glyphs don't jump as the
|
||||||
* Class and style are single short bindings so the formatter keeps
|
// slider moves; `computeStrutHeight` explains the why and the formula.
|
||||||
* `<span ...>{text}</span>` on one line. A wrapped text expression would leak
|
const strutHeightPx = $derived(computeStrutHeight(lineHeightPx, fontSizePx));
|
||||||
* 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`,
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -91,15 +66,19 @@ const strutStyle = $derived(
|
|||||||
style:letter-spacing="{letterSpacingPx}px"
|
style:letter-spacing="{letterSpacingPx}px"
|
||||||
style:font-weight={typography.weight}
|
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}
|
{#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}
|
{/if}
|
||||||
{#each model.windowChars as wc (wc.key)}
|
{#each model.windowChars as wc (wc.key)}
|
||||||
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
|
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if model.rightText}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 });
|
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.)
|
// Track container width changes (window resize, sidebar toggle, etc.)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -344,7 +338,7 @@ $effect(() => {
|
|||||||
>
|
>
|
||||||
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
||||||
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
|
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
|
||||||
<Line {line} {split} windowSize={WINDOW_SIZE} />
|
<Line {line} {split} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user