Compare commits
6
Commits
main
...
0e9288c295
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e9288c295 | ||
|
|
dbd48b287d | ||
|
|
f29e0b0c7c | ||
|
|
91bb046339 | ||
|
|
f680fe01ea | ||
|
|
d37d01e6d8 |
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}"`;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user