import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";import { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, TERMINAL_FONT_SIZE_STEP, normalizeTerminalFontSize,} from "../features/terminal/terminalPreferences"; export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize;export const MIN_BASE_FONT_SIZE = 11;export const MAX_BASE_FONT_SIZE = 22;export const BASE_FONT_SIZE_STEP = 1; export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize;export const MIN_CODE_FONT_SIZE = 8;export const MAX_CODE_FONT_SIZE = 18;export const CODE_FONT_SIZE_STEP = 1; /** * User-configurable appearance preferences as stored. `null` overrides mean * "automatic": the value is derived from the base font size. */export interface AppearancePreferences { readonly baseFontSize: number; readonly terminalFontSize: number | null; readonly codeFontSize: number | null; readonly codeWordBreak: boolean;} /** Effective appearance values after applying base-size derivation. */export interface ResolvedAppearance { readonly baseFontSize: number; readonly terminalFontSize: number; readonly codeFontSize: number; readonly codeWordBreak: boolean; readonly isTerminalFontSizeCustom: boolean; readonly isCodeFontSizeCustom: boolean;} export interface ResolvedMobileCodeSurface { readonly fontSize: number; readonly lineNumberFontSize: number; readonly rowHeight: number; readonly gutterWidth: number; readonly codePadding: number; readonly textVerticalInset: number;} export interface ResolvedMarkdownFontSizes { readonly s: number; readonly m: number; readonly h1: number; readonly h2: number; readonly h3: number; readonly h4: number; readonly h5: number; readonly h6: number; readonly bodyLineHeight: number; readonly codeBlockFontSize: number; readonly codeBlockLineHeight: number;} export interface NativeMarkdownTypography { readonly fontSize: number; readonly lineHeight: number; readonly headingFontSizes: readonly [number, number, number, number, number, number];} export function normalizeBaseFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_BASE_FONT_SIZE; } return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value)));} function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value)));} function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true;} /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2);} /** Code/diff size derived from base: 12pt at base 16. */function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale));} interface StoredAppearancePreferences { readonly baseFontSize?: number | null | undefined; /** Legacy key from before base font size existed; migrated to baseFontSize. */ readonly markdownFontSize?: number | null | undefined; readonly terminalFontSize?: number | null | undefined; readonly codeFontSize?: number | null | undefined; readonly codeWordBreak?: boolean | null | undefined;} export function resolveAppearancePreferences( stored: StoredAppearancePreferences | null | undefined,): AppearancePreferences { return { baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize), terminalFontSize: typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize) ? normalizeTerminalFontSize(stored.terminalFontSize) : null, codeFontSize: typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize) ? normalizeCodeFontSize(stored.codeFontSize) : null, codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak), };} export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance { return { baseFontSize: preferences.baseFontSize, terminalFontSize: preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize), codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize), codeWordBreak: preferences.codeWordBreak, isTerminalFontSizeCustom: preferences.terminalFontSize !== null, isCodeFontSizeCustom: preferences.codeFontSize !== null, };} export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface { const fontSize = normalizeCodeFontSize(codeFontSize); const scale = fontSize / DEFAULT_CODE_FONT_SIZE; return { fontSize, lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)), rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)), gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, codePadding: MOBILE_CODE_SURFACE.codePadding, textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, };} export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes { const m = normalizeBaseFontSize(baseFontSize); const scale = m / DEFAULT_BASE_FONT_SIZE; const codeBlockFontSize = Math.max(10, Math.round(13 * scale)); return { s: Math.max(10, Math.round(14 * scale)), m, h1: Math.max(16, Math.round(21 * scale)), h2: Math.max(14, Math.round(19 * scale)), h3: Math.max(13, Math.round(17 * scale)), h4: Math.max(12, Math.round(15 * scale)), h5: Math.max(12, Math.round(15 * scale)), h6: Math.max(12, Math.round(15 * scale)), bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)), codeBlockFontSize, codeBlockLineHeight: codeBlockFontSize + 6, };} /** * Maps the Uniwind `--text-*` theme variables (see global.css) to the * MOBILE_TYPOGRAPHY roles they were authored from. Keep in sync with both. */const TEXT_SCALE_VARIABLE_ROLES = { "--text-3xs": MOBILE_TYPOGRAPHY.micro, "--text-2xs": MOBILE_TYPOGRAPHY.caption, "--text-xs": MOBILE_TYPOGRAPHY.label, "--text-sm": MOBILE_TYPOGRAPHY.footnote, "--text-base": MOBILE_TYPOGRAPHY.body, "--text-lg": MOBILE_TYPOGRAPHY.headline, "--text-xl": MOBILE_TYPOGRAPHY.title, "--text-2xl": MOBILE_TYPOGRAPHY.largeTitle, "--text-3xl": MOBILE_TYPOGRAPHY.display,} as const; /** * Scaled values for every `--text-*` size and line-height variable, ready to * pass to `Uniwind.updateCSSVariables`. All className-based text (`text-sm`, * `text-base`, ...) re-resolves live when these are injected. */export function resolveTextScaleVariables(baseFontSize: number): Record<string, number> { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; const variables: Record<string, number> = {}; for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) { variables[name] = Math.max(8, Math.round(role.fontSize * scale)); variables[`${name}--line-height`] = scaledTypographyLineHeight(role, baseFontSize); } return variables;} /** * The line height a MOBILE_TYPOGRAPHY role renders at under the given base * font size — the same value resolveTextScaleVariables injects for the role's * `--text-*--line-height` variable. For layout code that must predict * text-driven heights (e.g. the thread feed's fixed item sizes) instead of * measuring them. */export function scaledTypographyLineHeight( role: { readonly lineHeight: number }, baseFontSize: number,): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return Math.max(10, Math.round(role.lineHeight * scale));} export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { const fontSizes = resolveMarkdownFontSizes(baseFontSize); return { fontSize: fontSizes.m, lineHeight: fontSizes.bodyLineHeight, headingFontSizes: [ fontSizes.h1, fontSizes.h2, fontSizes.h3, fontSizes.h4, fontSizes.h5, fontSizes.h6, ], };} export function stepTerminalFontSize(current: number, direction: -1 | 1): number { const next = direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; return normalizeTerminalFontSize(next);} export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, TERMINAL_FONT_SIZE_STEP, normalizeTerminalFontSize,};
import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";
import {
DEFAULT_TERMINAL_FONT_SIZE,
MAX_TERMINAL_FONT_SIZE,
MIN_TERMINAL_FONT_SIZE,
TERMINAL_FONT_SIZE_STEP,
normalizeTerminalFontSize,
} from "../features/terminal/terminalPreferences";
export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize;
export const MIN_BASE_FONT_SIZE = 11;
export const MAX_BASE_FONT_SIZE = 22;
export const BASE_FONT_SIZE_STEP = 1;
export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize;
export const MIN_CODE_FONT_SIZE = 8;
export const MAX_CODE_FONT_SIZE = 18;
export const CODE_FONT_SIZE_STEP = 1;
/**
* User-configurable appearance preferences as stored. `null` overrides mean
* "automatic": the value is derived from the base font size.
*/
export interface AppearancePreferences {
readonly baseFontSize: number;
readonly terminalFontSize: number | null;
readonly codeFontSize: number | null;
readonly codeWordBreak: boolean;
}
/** Effective appearance values after applying base-size derivation. */
export interface ResolvedAppearance {
readonly baseFontSize: number;
readonly terminalFontSize: number;
readonly codeFontSize: number;
readonly codeWordBreak: boolean;
readonly isTerminalFontSizeCustom: boolean;
readonly isCodeFontSizeCustom: boolean;
}
export interface ResolvedMobileCodeSurface {
readonly fontSize: number;
readonly lineNumberFontSize: number;
readonly rowHeight: number;
readonly gutterWidth: number;
readonly codePadding: number;
readonly textVerticalInset: number;
}
export interface ResolvedMarkdownFontSizes {
readonly s: number;
readonly m: number;
readonly h1: number;
readonly h2: number;
readonly h3: number;
readonly h4: number;
readonly h5: number;
readonly h6: number;
readonly bodyLineHeight: number;
readonly codeBlockFontSize: number;
readonly codeBlockLineHeight: number;
}
export interface NativeMarkdownTypography {
readonly fontSize: number;
readonly lineHeight: number;
readonly headingFontSizes: readonly [number, number, number, number, number, number];
}
export function normalizeBaseFontSize(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_BASE_FONT_SIZE;
}
return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value)));
}
function normalizeCodeFontSize(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_CODE_FONT_SIZE;
}
return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value)));
}
function normalizeCodeWordBreak(value: boolean | null | undefined): boolean {
return value === true;
}
/** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */
function deriveTerminalFontSize(baseFontSize: number): number {
const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2);
}
/** Code/diff size derived from base: 12pt at base 16. */
function deriveCodeFontSize(baseFontSize: number): number {
const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale));
}
interface StoredAppearancePreferences {
readonly baseFontSize?: number | null | undefined;
/** Legacy key from before base font size existed; migrated to baseFontSize. */
readonly markdownFontSize?: number | null | undefined;
readonly terminalFontSize?: number | null | undefined;
readonly codeFontSize?: number | null | undefined;
readonly codeWordBreak?: boolean | null | undefined;
}
export function resolveAppearancePreferences(
stored: StoredAppearancePreferences | null | undefined,
): AppearancePreferences {
return {
baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize),
terminalFontSize:
typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize)
? normalizeTerminalFontSize(stored.terminalFontSize)
: null,
codeFontSize:
typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize)
? normalizeCodeFontSize(stored.codeFontSize)
: null,
codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak),
};
}
export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance {
return {
baseFontSize: preferences.baseFontSize,
terminalFontSize:
preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize),
codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize),
codeWordBreak: preferences.codeWordBreak,
isTerminalFontSizeCustom: preferences.terminalFontSize !== null,
isCodeFontSizeCustom: preferences.codeFontSize !== null,
};
}
export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface {
const fontSize = normalizeCodeFontSize(codeFontSize);
const scale = fontSize / DEFAULT_CODE_FONT_SIZE;
return {
fontSize,
lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)),
rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)),
gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
codePadding: MOBILE_CODE_SURFACE.codePadding,
textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
};
}
export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes {
const m = normalizeBaseFontSize(baseFontSize);
const scale = m / DEFAULT_BASE_FONT_SIZE;
const codeBlockFontSize = Math.max(10, Math.round(13 * scale));
return {
s: Math.max(10, Math.round(14 * scale)),
m,
h1: Math.max(16, Math.round(21 * scale)),
h2: Math.max(14, Math.round(19 * scale)),
h3: Math.max(13, Math.round(17 * scale)),
h4: Math.max(12, Math.round(15 * scale)),
h5: Math.max(12, Math.round(15 * scale)),
h6: Math.max(12, Math.round(15 * scale)),
bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)),
codeBlockFontSize,
codeBlockLineHeight: codeBlockFontSize + 6,
};
}
/**
* Maps the Uniwind `--text-*` theme variables (see global.css) to the
* MOBILE_TYPOGRAPHY roles they were authored from. Keep in sync with both.
*/
const TEXT_SCALE_VARIABLE_ROLES = {
"--text-3xs": MOBILE_TYPOGRAPHY.micro,
"--text-2xs": MOBILE_TYPOGRAPHY.caption,
"--text-xs": MOBILE_TYPOGRAPHY.label,
"--text-sm": MOBILE_TYPOGRAPHY.footnote,
"--text-base": MOBILE_TYPOGRAPHY.body,
"--text-lg": MOBILE_TYPOGRAPHY.headline,
"--text-xl": MOBILE_TYPOGRAPHY.title,
"--text-2xl": MOBILE_TYPOGRAPHY.largeTitle,
"--text-3xl": MOBILE_TYPOGRAPHY.display,
} as const;
/**
* Scaled values for every `--text-*` size and line-height variable, ready to
* pass to `Uniwind.updateCSSVariables`. All className-based text (`text-sm`,
* `text-base`, ...) re-resolves live when these are injected.
*/
export function resolveTextScaleVariables(baseFontSize: number): Record<string, number> {
const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
const variables: Record<string, number> = {};
for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) {
variables[name] = Math.max(8, Math.round(role.fontSize * scale));
variables[`${name}--line-height`] = scaledTypographyLineHeight(role, baseFontSize);
}
return variables;
}
/**
* The line height a MOBILE_TYPOGRAPHY role renders at under the given base
* font size — the same value resolveTextScaleVariables injects for the role's
* `--text-*--line-height` variable. For layout code that must predict
* text-driven heights (e.g. the thread feed's fixed item sizes) instead of
* measuring them.
*/
export function scaledTypographyLineHeight(
role: { readonly lineHeight: number },
baseFontSize: number,
): number {
const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
return Math.max(10, Math.round(role.lineHeight * scale));
}
export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
const fontSizes = resolveMarkdownFontSizes(baseFontSize);
return {
fontSize: fontSizes.m,
lineHeight: fontSizes.bodyLineHeight,
headingFontSizes: [
fontSizes.h1,
fontSizes.h2,
fontSizes.h3,
fontSizes.h4,
fontSizes.h5,
fontSizes.h6,
],
};
}
export function stepTerminalFontSize(current: number, direction: -1 | 1): number {
const next =
direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP;
return normalizeTerminalFontSize(next);
}
export {
DEFAULT_TERMINAL_FONT_SIZE,
MAX_TERMINAL_FONT_SIZE,
MIN_TERMINAL_FONT_SIZE,
TERMINAL_FONT_SIZE_STEP,
normalizeTerminalFontSize,
};