Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions components/ds/Text.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Text as RNText, type TextProps } from 'react-native';
import { Platform, Text as RNText, type TextProps } from 'react-native';

import { displayFaces, fontFamilies, type, type TypeStyle } from '@/constants/tokens';
import { displayFaces, fontFamilies, textSlack, type, type TypeStyle } from '@/constants/tokens';

import { useDs } from './theme';

Expand All @@ -27,6 +27,17 @@ const FAMILY: Record<TypeStyle, string> = {
caption: fontFamilies.bodyMedium,
};

// iOS lays text out in a box exactly the size of its frame. Yoga rounds that
// frame to the pixel grid in float32, and a paragraph on a fractional offset
// (below a 1/3 pt hairline, say) that crosses y = 1024, 2048... comes out a
// hair shorter than the text it measured. TextKit then fits one line fewer and
// clips the rest onto the last line: one line running off the edge, with the
// full height still reserved. A sliver of padding makes the height fractional,
// and Yoga rounds a fractional text height up, so the box always fits.
// ponytail: an app-side guard for react/yoga#2011; drop it once React Native
// ships that fix.
const SLACK = Platform.OS === 'ios' ? { paddingBottom: textSlack } : null;

/**
* Text in one of the six Back Bar styles. Display and title use the venue's
* display face; everything else is fixed so every venue stays legible.
Expand All @@ -48,6 +59,7 @@ export function DsText({ variant = 'body', tone = 'ink', italic, color, align, s
{...rest}
style={[
type[variant],
SLACK,
{
fontFamily,
color: color ?? toneColor,
Expand Down
49 changes: 49 additions & 0 deletions components/ds/textSlack.check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Checks that textSlack keeps iOS text frames at least as tall as the text.
// Models Yoga's roundLayoutResultsToPixelGrid for a text node (React Native
// 0.86): each edge is rounded in double, stored as float32, then subtracted.
// Run: npm run test:unit
import assert from 'node:assert/strict';

import { textSlack, type } from '../../constants/tokens';

const f32 = Math.fround;
const near = (a: number, b: number) => Math.abs(a - b) < 0.0001;

function roundToGrid(value: number, scale: number, ceil: boolean, floor: boolean): number {
let scaled = value * scale;
let frac = scaled % 1;
if (frac < 0) frac += 1;
if (near(frac, 0)) scaled -= frac;
else if (near(frac, 1)) scaled += 1 - frac;
else if (ceil) scaled += 1 - frac;
else if (floor) scaled -= frac;
else scaled += (frac >= 0.5 ? 1 : 0) - frac;
return f32(scaled / scale);
}

/** The height of the box iOS draws a text node's content in. */
function drawnHeight(top: number, content: number, slack: number, scale: number): number {
const height = f32(content + slack);
const fractional = !near(Math.round(height * scale), height * scale);
const frame = f32(roundToGrid(top + height, scale, fractional, !fractional) - roundToGrid(top, scale, false, true));
return frame - f32(slack);
}

// Every Back Bar line height, one to four lines, on every pixel-grid offset
// down a long page, on 2x and 3x screens.
let shortWithout = 0;
for (const scale of [2, 3]) {
for (const { lineHeight } of Object.values(type)) {
for (let lines = 1; lines <= 4; lines++) {
const content = lineHeight * lines;
for (let px = 0; px < 5000 * scale; px++) {
const top = px / scale;
if (drawnHeight(top, content, 0, scale) < content) shortWithout++;
assert.ok(drawnHeight(top, content, textSlack, scale) >= content, `${content}pt at y=${top} on ${scale}x`);
}
}
}
}

// The model reproduces the bug, or the check above proves nothing.
assert.ok(shortWithout > 0, 'without slack some frames should come out short');
7 changes: 7 additions & 0 deletions constants/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ export const type = {

export type TypeStyle = keyof typeof type;

/**
* Bottom padding, in points, under every text style on iOS, so a paragraph
* never draws a line short. Why: components/ds/Text.tsx. Checked by
* components/ds/textSlack.check.ts.
*/
export const textSlack = 0.01;

/** `mark`: shapes drawn inside a card that can be only a few points tall, like zones on the back bar plan. */
export const radius = { mark: 4, control: 12, card: 22, sheet: 36, pill: 999 } as const;

Expand Down
Loading