Feature/adaptive crossfade window #50

Merged
ilia merged 7 commits from feature/adaptive-crossfade-window into main 2026-06-06 06:05:09 +00:00
5 changed files with 123 additions and 33 deletions
Showing only changes of commit 11d5ba0e63 - Show all commits
+1
View File
@@ -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);
}
+12 -33
View File
@@ -14,8 +14,10 @@ import {
windowSizeForLine, 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 {
/** /**
@@ -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>