Skip to content

feat(native-list): add the per-template row style and apply it on Web - #109

Open
huhuanming wants to merge 1 commit into
codex/native-list-style-specfrom
codex/native-list-row-style
Open

huhuanming wants to merge 1 commit into
codex/native-list-style-specfrom
codex/native-list-row-style

Conversation

@huhuanming

Copy link
Copy Markdown
Contributor

Stacked on #107 — review that one first; this PR targets its branch, so the diff here is P1 only.

What

The contract and the Web implementation for the per-template row style described in docs/STYLE_SPEC.md.

Style keys name model fields, not views

The view pool is shared across templates and the mapping is not one to one — metricCard renders its value through the label identity uses for its title, and a single status view carries rail.status, activity.status, message.time, and metricCard.trend. Keys named after views would mis-target, so style.X always modifies the model field named X:

{ type: 'metricCard', key: 'kpi', title: 'Volume', value: '42',
  style: { title: { token: '$bodyXs' }, value: { fontSize: 22, fontWeight: 'bold' } } }

market already followed this rule with style.price / style.change; its type is now an extension of the shared RowBoxStyle and its shape is unchanged.

Tokens resolve at the one shared boundary

The three renderers cannot constrain each other, but they all read the same serialized snapshot. { token: '$bodyLg' } is resolved to { fontSize: 16, lineHeight: 24, fontWeight: 'regular' } in validation.ts before serialization, so no native renderer learns the vocabulary and none can drift from it. An explicit value alongside a token still wins, raw numbers remain available for pixel-parity work, resolution is idempotent, and a snapshot with nothing to resolve is returned unchanged so the common path keeps object identity.

Patches are resolved the same way — they reach the native side without passing through a snapshot.

Isolation

Per STYLE_SPEC.md §7: style types are per template, and both validateSnapshot and validatePatches reject a key the row type does not declare.

NativeList rows[0].style.price: is not a style key of the "identity" template

Web

applyRowStyle() runs in renderElement() after createRowBody(). The element rendering a field carries data-nl-slot="<field>" — resolving by CSS class is not possible because .ok-native-list-secondary alone is the identity subtitle, the rail status, a metric label, a data column's secondary text, and a system message.

Covered Deferred
Text slots for all 13 templates leadingGap, trailingGap, titleBadgeGap, image — validated, but applied only on market
horizontalPadding, verticalPadding, lineGap

The deferred gaps need a per-template default that cannot be read back from the DOM; applying them would mean hard-coding a second copy of each template's spacing. Recorded in the spec's status table rather than guessed at.

Not changed

Row heights. Per the review decision on #107, the three height tables are left alone — a styled row that grows still needs an explicit row.height. This is recorded in STYLE_SPEC.md §6.1.

Verification

  • jest — 5 suites, 78 tests (was 68), all pass
  • tsc -b — clean
  • eslint — clean

New tests cover: token resolution and explicit override, snapshot identity preservation, rejection of undeclared keys / unknown tokens / out-of-range metrics, patch validation and resolution, the Market surface staying intact, slot tagging, styling the named field rather than the view, and box padding applying only when asked.

Next

P2 (iOS, Android) and P3 (list chrome, example page). The Android work additionally carries STYLE_SPEC.md §7 rule 2 — resetViews() does not restore textSize/typeface/lineHeight, so the style pass must write explicit defaults or a font size will leak into the next row that reuses the view.

🤖 Generated with Claude Code

Every template except market hard-codes its typography in three renderers, so
tuning one meant adding a presentation variant plus a branch per platform. Give
each template a bounded style instead.

A style key names the model field it modifies, never the view that carries it.
The view pool is shared and the mapping is not one to one: metricCard renders
its value through the label identity uses for its title, and one status view
carries rail.status, activity.status, message.time and metricCard.trend. So
metricCard.style.value reaches the large number, and identity.style.title the
title, through the same physical view.

Typography can be a named step from the application's scale
({ token: '$bodyLg' }). Tokens resolve to numbers in validation.ts before the
snapshot is serialized, so no native renderer learns the vocabulary and none can
drift from it; an explicit fontSize alongside a token still wins. Resolution is
idempotent and a snapshot with nothing to resolve is returned unchanged, so the
common path keeps object identity.

Isolation follows docs/STYLE_SPEC.md §7: style types are per template, and
validateSnapshot and validatePatches reject a key the row type does not declare,
so a style written for one template cannot reach a shared view through another.
Styles carried by a patch are validated and resolved the same way, because
patches reach the native side without passing through a snapshot.

Web applies it in renderElement after createRowBody. The element rendering a
field carries data-nl-slot, because .ok-native-list-secondary alone is the
identity subtitle, the rail status, a metric label, a data column's secondary
text and a system message. Text slots work for every template, along with
horizontalPadding, verticalPadding and lineGap. leadingGap, trailingGap,
titleBadgeGap and image stay validated but applied only on market: the others
need a per-template default gap that cannot be read back from the DOM.

Row heights are untouched. A styled row that grows still needs an explicit
height, as recorded in docs/STYLE_SPEC.md §6.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
badge?: NativeListTextStyle;
}>;

export type MetricCardRowStyle = RowBoxStyle &

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [Composite metricCard styles are ignored]

When metricCard.metrics is non-empty, createMetricRow takes the composite path. That path renders the heading and metric cells without data-nl-slot attributes, while applyRowStyle only applies text styles by selecting those slots.

As a result, accepted MetricCardRowStyle fields such as title and value silently have no effect for composite metric cards. Tag the composite elements with the documented field slots (or reject unsupported styles for that variant) and add a composite-card style test.

};
const body = createRowBody(context, row);
applySelectorTabularNumbers(body, row);
applyRowStyle(body, row);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [Selector title styles are overwritten after application]

For an identity row with an explicit height and presentation: 'accountSelector' or 'networkSelector', this call applies style.title before the selector branch below writes its own title metrics. The network-selector branch overwrites font size, line height, and weight; the account-selector branch overwrites line height.

That makes a valid caller style ineffective on these selector rows. Apply the caller style after the selector defaults, or only write each selector default when that style field is absent, and cover both presentations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants