6 Commits
16 changed files with 282 additions and 0 deletions
@@ -0,0 +1,20 @@
import {
describe,
expect,
it,
} from 'vitest';
import { comboKey } from './comboKey';
describe('comboKey', () => {
it('derives a key from the two font ids', () => {
expect(comboKey({ id: 'x', headerFontId: 'Inter', bodyFontId: 'Lora' })).toBe('Inter|Lora');
});
it('ignores the surrogate id (content not identity)', () => {
const a = comboKey({ id: 'a', headerFontId: 'Inter', bodyFontId: 'Lora' });
const b = comboKey({ id: 'b', headerFontId: 'Inter', bodyFontId: 'Lora' });
expect(a).toBe(b);
});
it('is order-sensitive on role', () => {
expect(comboKey({ id: 'x', headerFontId: 'Lora', bodyFontId: 'Inter' })).toBe('Lora|Inter');
});
});
@@ -0,0 +1,13 @@
import type { Pairing } from '../types';
/**
* Natural key describing a Pairing's current fonts (not its identity).
* Used for URL share-encoding and "is this combo already on the board" checks.
* Recomputed on swap; two cards may share a comboKey but never an id.
*
* @param pairing - The pairing whose fonts form the key (its `id` is ignored).
* @returns The `headerFontId|bodyFontId` key.
*/
export function comboKey(pairing: Pairing): string {
return `${pairing.headerFontId}|${pairing.bodyFontId}`;
}
@@ -0,0 +1,22 @@
import {
describe,
expect,
it,
} from 'vitest';
import { createPairing } from './createPairing';
describe('createPairing', () => {
it('builds a pairing from two font ids', () => {
const p = createPairing('Inter', 'Lora');
expect(p.headerFontId).toBe('Inter');
expect(p.bodyFontId).toBe('Lora');
});
it('generates a unique id each call (duplicates stay distinct)', () => {
const a = createPairing('Inter', 'Lora');
const b = createPairing('Inter', 'Lora');
expect(a.id).not.toBe(b.id);
});
it('accepts an explicit id for rehydration', () => {
expect(createPairing('Inter', 'Lora', 'fixed-id').id).toBe('fixed-id');
});
});
@@ -0,0 +1,15 @@
import type { Pairing } from '../types';
/**
* Creates a Pairing with a fresh surrogate id (or a supplied one when
* rehydrating from storage). The id is identity, never content — two pairings
* with the same fonts are still distinct cards.
*
* @param headerFontId - Font entity id for the header role.
* @param bodyFontId - Font entity id for the body role.
* @param id - Explicit id for rehydration; defaults to a fresh UUID.
* @returns The new Pairing.
*/
export function createPairing(headerFontId: string, bodyFontId: string, id: string = crypto.randomUUID()): Pairing {
return { id, headerFontId, bodyFontId };
}
+3
View File
@@ -0,0 +1,3 @@
export { comboKey } from './comboKey/comboKey';
export { createPairing } from './createPairing/createPairing';
export { nextFocalId } from './nextFocalId/nextFocalId';
@@ -0,0 +1,32 @@
import {
describe,
expect,
it,
} from 'vitest';
import { nextFocalId } from './nextFocalId';
const ids = ['a', 'b', 'c'];
describe('nextFocalId', () => {
it('steps forward', () => {
expect(nextFocalId(ids, 'a', 1)).toBe('b');
});
it('steps backward', () => {
expect(nextFocalId(ids, 'b', -1)).toBe('a');
});
it('wraps forward at the end', () => {
expect(nextFocalId(ids, 'c', 1)).toBe('a');
});
it('wraps backward at the start', () => {
expect(nextFocalId(ids, 'a', -1)).toBe('c');
});
it('returns the only id when list has one', () => {
expect(nextFocalId(['solo'], 'solo', 1)).toBe('solo');
});
it('returns current when focal id is absent', () => {
expect(nextFocalId(ids, 'missing', 1)).toBe('missing');
});
it('returns null for an empty list', () => {
expect(nextFocalId([], 'x', 1)).toBeNull();
});
});
@@ -0,0 +1,21 @@
/**
* The id one step from `currentId` in board order, wrapping at both ends.
*
* @param orderedIds - Pairing ids in board order.
* @param currentId - The currently focal id to step from.
* @param direction - +1 for next, -1 for previous.
* @returns The neighbouring id (wrapped), `currentId` unchanged if it isn't in
* the list, or null for an empty list.
*/
export function nextFocalId(orderedIds: string[], currentId: string, direction: 1 | -1): string | null {
if (orderedIds.length === 0) {
return null;
}
const i = orderedIds.indexOf(currentId);
if (i === -1) {
return currentId;
}
const len = orderedIds.length;
const next = (i + direction + len) % len;
return orderedIds[next];
}
@@ -0,0 +1,4 @@
export type {
Pairing,
Role,
} from './pairing';
@@ -0,0 +1,26 @@
/**
* A slot within a Pairing that a font fills.
*/
export type Role = 'header' | 'body';
/**
* The atomic unit of comparison: a header font + a body font.
* Carries a surrogate `id` (stable for the card's life, never tracks content)
* and the two font ids it pairs. Text and typography are global to the Board,
* not stored here.
*/
export interface Pairing {
/**
* Surrogate key generated at creation, stable for the card's life.
* Distinguishes duplicates with identical fonts. Focal/cycling key on this.
*/
id: string;
/**
* Font entity id filling the header role.
*/
headerFontId: string;
/**
* Font entity id filling the body role.
*/
bodyFontId: string;
}
+9
View File
@@ -0,0 +1,9 @@
export {
comboKey,
createPairing,
nextFocalId,
} from './domain';
export type {
Pairing,
Role,
} from './model/types';
@@ -0,0 +1,4 @@
export type {
Pairing,
Role,
} from './pairing';
@@ -0,0 +1,9 @@
/**
* Re-export of the Pairing identity types. The source of truth lives in
* `domain/types` so the pure domain segment can reference them without importing
* `model` (FSD+ domain isolation: ui -> model -> domain, never back).
*/
export type {
Pairing,
Role,
} from '../../domain/types';
@@ -0,0 +1,71 @@
/**
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
*
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
* after that — `measureText` keeps returning a fallback width for some frames
* even though `document.fonts.check()` reports the font as loaded.
*
* Pretext caches measurements per font string forever, so a single fallback
* measurement during initial mount permanently poisons the cache and the
* rendered text drifts visibly from its measured box. This helper polls canvas
* measurement until each font reports a width that differs from the "unknown
* font family" fallback, guaranteeing the next `measureText` call sees the real
* glyph metrics.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version — ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. The poll
* logic is the proven fix for Pretext's fallback-width cache poisoning; copying
* it is cheaper than refactoring frozen code.
*
* @param fontStrings - Pretext/canvas font strings (`weight sizepx "family"`) to warm.
*/
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
const PROBE_TEXT = 'mmmmmmmmmm';
const MAX_WAIT_MS = 1000;
const DEFAULT_PROBE_SIZE_PX = 16;
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
// registration timing, so we MUST poll using the same canvas type pretext does.
const ctx = typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1).getContext('2d')
: document.createElement('canvas').getContext('2d');
if (!ctx) {
return;
}
// Measure each font's "unknown font" fallback width (different per browser, per OS).
// Canvas uses this same fallback for any font family it can't resolve, so when the
// requested font finally registers, measureText will return a non-fallback width.
const fallbackWidths = new Map<string, number>();
for (const font of fontStrings) {
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
}
const deadline = performance.now() + MAX_WAIT_MS;
const pending = new Set(fontStrings);
while (pending.size > 0 && performance.now() < deadline) {
for (const font of Array.from(pending)) {
ctx.font = font;
const w = ctx.measureText(PROBE_TEXT).width;
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
pending.delete(font);
}
}
if (pending.size === 0) {
break;
}
// Sequential by design: poll once per animation frame until fonts register.
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
}
@@ -0,0 +1,15 @@
import {
describe,
expect,
it,
} from 'vitest';
import { getPretextFontString } from './getPretextFontString';
describe('getPretextFontString', () => {
it('formats weight, px size and quoted family for pretext/canvas', () => {
expect(getPretextFontString(400, 48, 'Inter')).toBe('400 48px "Inter"');
});
it('preserves fractional sizes and quotes multi-word family names', () => {
expect(getPretextFontString(700, 12.5, 'PT Serif')).toBe('700 12.5px "PT Serif"');
});
});
@@ -0,0 +1,16 @@
/**
* Formats a font config into the string `@chenglou/pretext` and the Canvas 2D
* `font` property both expect: `weight sizepx "family"`.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version — ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. Three lines
* is cheaper to duplicate than to refactor frozen code.
*
* @param weight - Numeric font weight (e.g. 400).
* @param sizePx - Font size in pixels.
* @param fontName - The font family name.
* @returns A formatted font string: `weight sizepx "fontName"`.
*/
export function getPretextFontString(weight: number, sizePx: number, fontName: string): string {
return `${weight} ${sizePx}px "${fontName}"`;
}
+2
View File
@@ -17,7 +17,9 @@ export {
export { clampNumber } from './clampNumber/clampNumber'; export { clampNumber } from './clampNumber/clampNumber';
export { cn } from './cn'; export { cn } from './cn';
export { debounce } from './debounce/debounce'; export { debounce } from './debounce/debounce';
export { ensureCanvasFonts } from './ensureCanvasFonts/ensureCanvasFonts';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getPretextFontString } from './getPretextFontString/getPretextFontString';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth'; export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll'; export { smoothScroll } from './smoothScroll/smoothScroll';