vercel-labs / vercel-labs/native

Line height is unreachable from apps: `widgetTextSpanLayoutOptions` never sets the `line_height` that layout already honors

Open
#215 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Zig
Stars
7.7k
Forks
314
Avg merge
5h
Merged PRs (30d)
13

Description

TextSpanLayoutOptions.line_height and TextLayoutOptions.line_height are real, honored fields — layout reads them and falls back to 1.25 only when they are zero. But nothing in the widget tier ever sets them, and there is no token or attribute that would, so no app, theme, or .native document can influence line height at all. The knob is plumbed end to end and unreachable from outside.

To be clear up front: I am not asking to change the 1.25 default. tokens.zig:339-349 reasons about it deliberately ("a heading's 1.25 line height (35) still composes with body lines on the same 4pt-friendly rhythm", "48 keeps the even-number rhythm with a 1.25 line height of exactly 60"), and that reasoning is sound. I am asking for it to be themeable, the way every other typography value already is.

The gap

widgetTextSpanLayoutOptions is documented as "the single source of truth for how a span paragraph lays out" — intrinsic sizing, wrapped-height reservation, link hit areas, and command emission all build their options here. It constructs six fields and omits the seventh (widget_metrics.zig:102-111):

pub fn widgetTextSpanLayoutOptions(widget: Widget, tokens: DesignTokens, max_width: f32) TextSpanLayoutOptions {
    return .{
        .size = widgetBodyTextSize(widget, tokens),
        .max_width = max_width,
        .wrap = .word,
        .alignment = widget.text_alignment,
        .typography = tokens.typography,
        .measure = tokens.text_measure,
    };            // .line_height never set -> 0 -> falls back to 1.25
}

The field it omits is honored immediately downstream (text_spans.zig:168-171, and text_layout.zig:886 for the single-style path):

pub fn textSpanLineHeight(spans: []const TextSpan, options: TextSpanLayoutOptions) f32 {
    if (options.line_height > 0) return options.line_height;
    return options.size * textSpansMaxScale(spans) * 1.25;
}

It is honored the rest of the way too — serialization.zig:187 (JSON) and :1444 (binary) both carry it, and the macOS host applies it at appkit_host.m:2555-2556 via paragraph.minimumLineHeight / maximumLineHeight. Everything works. The value simply never arrives, because there is nowhere for an app to put it: TypographyTokens (tokens.zig:322-350) has font ids, four size rungs, button_font_id, heading_size and display_size, but no leading; and the markup attribute set has no line-height.

That last part is what makes it a dead end rather than an inconvenience. ui_markup.zig:1210 tells authors that token retheming is the sanctioned way to move typography:

numeric sizes are not accepted by design - retheme the typography tokens (TypographyTokenOverrides) to move the whole scale

There is no leading in those tokens to retheme.

What it costs

I am building a social feed on the canvas. Its design sets body copy at 14.5/1.55 — 22.475px per line. The engine gives 14.5 x 1.25 = 18.125px. That is 4.35px per line, ~19% tighter, on every wrapped note in the feed, and no row can match its intended height. My height estimator is pinned to the engine's number rather than the design's, with the discrepancy written in as a comment, because there is nothing else to pin it to.

Prose-heavy UI is where this shows. 1.25 is a good ratio for labels and controls, which is most of what a widget toolkit draws; it is tight for body copy, which is most of what a reading app draws.

Proposed fix

A token, defaulting to today's value so nothing changes for anyone:

// tokens.zig, TypographyTokens
line_height_ratio: f32 = 1.25,

mirrored in TypographyTokenOverrides, and then set in the one constructor:

 pub fn widgetTextSpanLayoutOptions(widget: Widget, tokens: DesignTokens, max_width: f32) TextSpanLayoutOptions {
     return .{
         .size = widgetBodyTextSize(widget, tokens),
         .max_width = max_width,
         .wrap = .word,
         .alignment = widget.text_alignment,
         .typography = tokens.typography,
         .measure = tokens.text_measure,
+        .line_height = widgetBodyTextSize(widget, tokens) * tokens.typography.line_height_ratio,
     };
 }

With the default at 1.25 every existing app, golden, and fingerprint is byte-identical.

The catch, stated honestly

widgetLineHeight (widget_metrics.zig:68-70) reads like the canonical seam, but the render tier does not call it — 17 sites re-derive size * 1.25 inline, so a fix that only touches widgetLineHeight would silently desync paint from layout:

reference.zig:737 · text_layout.zig:459 · widget_metrics.zig:69 · widget_render.zig:957, 1643, 2102, 2445, 2612, 2716, 2730, 2752 · widget_render_controls.zig:1415, 1429, 1459 · widget_text_input.zig:529 · widget_text_select.zig:117, 132

(text_layout.zig:886 and text_spans.zig:170 are the two correct fallback sites; widget_render.zig:2059 is a chart axis gutter and terminal_grid.zig:780 is terminal cell metrics — all four unrelated.)

Routing those through widgetLineHeight(text_size, tokens) is most of the diff, and is worth doing regardless of this request — right now the ratio is a constant that seventeen places have to agree on by hand.

Deliberately not asking for

A line-height markup attribute, or numeric text sizes. Both are separate decisions with their own tradeoffs; the token alone gets a theme what it needs, and can be evaluated on its own.

Happy to contribute the implementation if the direction is agreeable.

Version: verified against v0.6.1; running CLI 0.5.3 / framework 57bf56bc in production, where every line above is identical.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with TypographyTokens in tokens.zig:322-350 and widgetTextSpanLayoutOptions in widget_metrics.zig:102-111, then trace the fallback and serialization paths in text_spans.zig, text_layout.zig, and serialization.zig. Audit the listed render files for inline 1.25 calculations and run the existing test or golden suites. Done means the token reaches layout and rendering consistently while the default preserves existing output.

Written by the indexing model from the issue text.

Assessment

Tech stack
zig
Domain
frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.